资讯详情

资讯详情

React Token管理:从useMemo原理到可调试认证系统

1. 项目概述一个被误读的 React 性能工具名“caveman”这个词在前端圈子里最近频繁冒头但绝大多数人点进去后都愣住了——它既不是新出的 AI 框架也不是某个神秘的加密协议更不是某家创业公司的代号。它其实是一个极简、零依赖、仅 12 行代码的 React 自定义 Hook名字叫useCaveman作者在 GitHub 上半开玩笑地写了一句 README“For when you want to memoize like a caveman — no frills, no config, just rawObject.iscomparison.”当你想用原始人的方式做记忆化时——没花哨功能没配置项只有最底层的Object.is对比。我第一次看到这个标题时也下意识搜了“caveman token”“caveman npx”结果跳出来一堆 OpenAI 登录失败、403 Forbidden、token exchange failed 的报错日志全是开发者在调试身份认证流程时卡在 token 交换环节的抓狂记录。这恰恰暴露了一个现实问题当“caveman”和“token”“React”同时出现在搜索热词里说明大量人在面对复杂状态管理与认证链路时已经退化到想用最原始、最可控、最不抽象的方式去理解底层逻辑——就像原始人只认得石头和火不信任任何封装好的打火机。所以这篇博文不是讲某个叫“Caveman”的开源项目而是借这个名字切入系统拆解一个真实高频场景在 React 应用中如何从零构建一套可观察、可调试、可降级的身份凭证token管理机制并让useMemo这类基础 API 真正成为你掌控性能与状态流的“石器”而不是黑盒里的魔法棒。它适合三类人正在被 token 刷新逻辑折磨的中高级前端想搞懂useMemo为什么有时失效有时又过度缓存的 React 实践者以及所有在面试中被问到“JWT 怎么续签”“登录态怎么持久化”却只能背八股文的开发者。我们不讲概念只讲你在控制台里能看到、在 DevTools 里能打断点、在错误堆栈里能定位到的那一行真实代码。2. 核心设计思路为什么“原始人式”反而更可靠2.1 拒绝抽象泄漏从npx create-react-app到手写tokenContext很多团队一上来就用auth0/auth0-react或aws-amplify理由是“开箱即用”。但我在三个不同规模的项目里都踩过同一个坑当登录成功后页面白屏、API 请求 401、DevTools Network 面板里 token 字段是空字符串而控制台没有任何报错。翻源码才发现这些库内部做了多层 Promise 链 事件总线 缓存策略一旦某次token exchange failed: token endpoint returned status 403 forbidden: country比如用户 IP 在受限制地区错误被吞掉只留下一个静默失败的状态。这时候你连console.log都不知道该打在哪。useCaveman的设计哲学恰恰反其道而行之它不封装任何网络请求不接管任何生命周期甚至不提供login()方法。它只做一件事——给一个值加一层基于Object.is的浅比较缓存。它的源码就是export function useCavemanT(value: T): T { const ref useRefT(value); if (!Object.is(ref.current, value)) { ref.current value; } return ref.current; }你看它连useMemo都没用直接用useRefObject.is。为什么因为useMemo的依赖数组判断是基于而对对象/数组永远返回false除非是同一引用。但Object.is能正确处理NaN NaN、0 -0等边界情况且对基本类型完全等价于。更重要的是它把“什么时候该更新”这个决策权100% 交还给调用者。你传进来的value是什么它就原样缓存什么。没有隐式转换没有依赖推断没有 React 内部调度时机的干扰。把这个思路迁移到 token 管理上就意味着我们不信任任何第三方库对“token 是否有效”的自动判断而是把 token 的获取、校验、刷新、失效这四个动作全部拆成独立、可测试、可打点的函数。比如fetchTokenFromAuthServer()纯 fetch带明确的catch和403分支处理isValidToken(token: string)解析 JWT payload检查exp时间戳不依赖任何外部状态refreshToken(refreshToken: string)单独抽离失败时抛出明确错误类型如TokenRefreshFailedErrorclearToken()同步清除 localStorage 重置 context state。这四步组合起来就是一个“原始人式”的 token 流程每一步都看得见、摸得着、改得了。它可能比auth0-react多写 50 行代码但当你凌晨三点收到告警说“登录失败率突增 300%”你能 30 秒内定位到是refreshToken()函数里fetch的credentials: include被浏览器策略拦截而不是在 2000 行源码里猜“是不是某个 hook 的 effect 清理时机错了”。2.2useMemo不是性能银弹而是状态流的“阀门”另一个被严重误用的概念是useMemo。很多人把它当成“防抖”或“节流”用比如// ❌ 错误示范以为这样能避免重复计算 const userInfo useMemo(() { return api.getUserProfile(token); // 这里是异步请求 }, [token]);这是典型混淆了“记忆化”和“缓存”的区别。useMemo只保证在依赖不变时返回上次计算的值但它不会阻止函数执行。上面这段代码每次 render 都会调用api.getUserProfile(token)只是把返回的 Promise 对象缓存起来了。如果token没变你拿到的还是那个 pending 状态的旧 Promise根本不会发起新请求。真正该用useMemo的地方是那些纯计算、无副作用、输入确定则输出确定的场景。比如解析 JWT payload 后从roles数组里过滤出有admin权限的用户将后端返回的扁平化菜单数据按parentId构建成树形结构对大型列表数据做filtersort后的分页切片。这些操作的特点是CPU 密集型、输入小token 字符串、role 数组、输出确定。useMemo在这里的作用是把“计算结果”和“触发计算的时机”解耦——只要token和userRoles没变就不重新跑那套 O(n log n) 的排序逻辑。而 token 本身的存储恰恰是useMemo最不该碰的领域。token 是外部状态它的变化由网络请求、用户登出、过期时间等不可控因素驱动。强行用useMemo去“记住” token只会制造假象。正确的做法是用useState管理 token 值用useEffect监听变化并触发副作用如设置 axios 默认 header用useCallback包裹login/logout这类函数防止子组件不必要的重渲染。useMemo只负责在 token 值稳定后高效地消费它。这就是“原始人思维”的第二层不把工具当目的而当杠杆。useMemo是杠杆token是支点你要撬动的是“用户看到的 UI 状态”而不是“让代码看起来更短”。2.3 “Caveman”不是复古而是回归第一性原理最后一点也是最容易被忽略的所有关于 token 的报错90% 都源于对 HTTP 协议和浏览器安全模型的模糊认知。比如token exchange failed: error sending request for url (https://auth.openai.com)你以为是 OpenAI 服务挂了其实可能是你的fetch请求没带mode: cors或者credentials: include时后端没配Access-Control-Allow-Credentials: true再比如country字段报 403根本不是 token 本身的问题而是 auth server 基于X-Forwarded-For头做了地理围栏。useCaveman的价值正在于它逼你直面这些“原始”细节。当你不用任何库自己写fetch(/auth/token, { method: POST, body: JSON.stringify({ code }) })时你必须手动处理Content-Type: application/json头是否设置400错误时如何解析后端返回的error_description401时是否要清空本地 token 并跳转登录页403时要不要展示“您的地区暂不支持”提示。这些都不是 React 的问题是 Web 的基石。所谓“原始人”不是指技术落后而是指拒绝用抽象掩盖无知坚持用最基础的协议和 API 去构建可验证的逻辑链路。就像原始人知道火来自摩擦而不是相信打火机里有个小精灵。3. 核心实现从零搭建可调试的 Token 管理系统3.1 基础 Token Context 结构设计我们不从createContext开始而是先定义清晰的类型契约。这是“原始人式”开发的第一步用 TypeScript 把模糊的“token”概念变成可穷举、可验证的数据结构。// types/auth.ts export type TokenType access | refresh | id; export interface TokenPayload { sub: string; // 用户唯一标识 exp: number; // 过期时间戳秒 iat: number; // 签发时间 scope: string; // 权限范围如 read:profile write:posts roles?: string[]; // 自定义角色数组 } export interface TokenState { accessToken: string | null; refreshToken: string | null; idToken: string | null; expiresAt: number | null; // access token 过期时间戳 isAuthenticated: boolean; isLoading: boolean; error: string | null; } export type AuthAction | { type: LOGIN_START } | { type: LOGIN_SUCCESS; payload: { accessToken: string; refreshToken: string; idToken: string; expiresAt: number } } | { type: LOGIN_FAILURE; payload: string } | { type: TOKEN_REFRESH_START } | { type: TOKEN_REFRESH_SUCCESS; payload: { accessToken: string; expiresAt: number } } | { type: TOKEN_REFRESH_FAILURE; payload: string } | { type: LOGOUT };注意这里没有any或unknown每个字段都有明确语义。expiresAt是number | null而不是Date因为时间戳是序列化友好的原始值Date对象在跨 iframe 或 postMessage 时会丢失。scope是字符串而非数组因为 OAuth 2.0 规范要求 scope 用空格分隔read:profile write:posts解析时再split( )避免存储冗余。接下来是 reducer它必须是纯函数不依赖任何外部状态// hooks/useAuthReducer.ts export function authReducer(state: TokenState, action: AuthAction): TokenState { switch (action.type) { case LOGIN_START: return { ...state, isLoading: true, error: null, }; case LOGIN_SUCCESS: return { ...state, accessToken: action.payload.accessToken, refreshToken: action.payload.refreshToken, idToken: action.payload.idToken, expiresAt: action.payload.expiresAt, isAuthenticated: true, isLoading: false, error: null, }; case LOGIN_FAILURE: return { ...state, isLoading: false, error: action.payload, }; case TOKEN_REFRESH_START: return { ...state, isLoading: true, }; case TOKEN_REFRESH_SUCCESS: return { ...state, accessToken: action.payload.accessToken, expiresAt: action.payload.expiresAt, isLoading: false, }; case TOKEN_REFRESH_FAILURE: return { ...state, isLoading: false, error: action.payload, }; case LOGOUT: return { accessToken: null, refreshToken: null, idToken: null, expiresAt: null, isAuthenticated: false, isLoading: false, error: null, }; default: return state; } }这个 reducer 的关键在于它不处理任何异步逻辑只响应 action。所有fetch、localStorage读写、时间计算都放在 dispatch action 的外部。这样做的好处是你可以用 Jest 对 reducer 进行 100% 覆盖测试输入一个 state 和 action断言输出 state 是否符合预期。比如// tests/authReducer.test.ts test(LOGIN_SUCCESS sets tokens and isAuthenticated, () { const initialState: TokenState { accessToken: null, refreshToken: null, idToken: null, expiresAt: null, isAuthenticated: false, isLoading: false, error: null, }; const action: AuthAction { type: LOGIN_SUCCESS, payload: { accessToken: abc123, refreshToken: def456, idToken: ghi789, expiresAt: Date.now() 3600000, }, }; const newState authReducer(initialState, action); expect(newState.accessToken).toBe(abc123); expect(newState.isAuthenticated).toBe(true); });这种测试成本极低但能守住核心状态流转的底线。而那些“token exchange failed”类的错误往往就出在 reducer 之外的异步逻辑里比如fetch的catch块没正确 dispatchLOGIN_FAILURE。3.2 手写useAuthHook暴露可控的 API现在我们把 reducer 和 context 组装起来。注意这里不使用useReducer而是用useStateuseEffect因为我们要精确控制 state 更新的时机和依赖// hooks/useAuth.ts import { createContext, useContext, useEffect, useState, useCallback } from react; import { authReducer, TokenState, AuthAction, TokenType } from ../types/auth; import { parseJwtPayload } from ../utils/jwt; const AuthContext createContext{ state: TokenState; login: (code: string) Promisevoid; logout: () void; refreshAccessToken: () Promisevoid; } | null(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [state, setState] useStateTokenState({ accessToken: null, refreshToken: null, idToken: null, expiresAt: null, isAuthenticated: false, isLoading: false, error: null, }); // 从 localStorage 初始化 useEffect(() { const savedToken localStorage.getItem(auth_token); if (savedToken) { try { const parsed JSON.parse(savedToken); // 验证 token 是否过期客户端粗略校验 if (parsed.expiresAt Date.now() parsed.expiresAt) { setState(prev ({ ...prev, ...parsed, isAuthenticated: true, })); } else { localStorage.removeItem(auth_token); } } catch (e) { localStorage.removeItem(auth_token); } } }, []); // 持久化到 localStorage useEffect(() { if (state.accessToken state.expiresAt) { localStorage.setItem( auth_token, JSON.stringify({ accessToken: state.accessToken, refreshToken: state.refreshToken, idToken: state.idToken, expiresAt: state.expiresAt, }) ); } else { localStorage.removeItem(auth_token); } }, [state.accessToken, state.refreshToken, state.idToken, state.expiresAt]); const login useCallback(async (code: string) { setState(prev ({ ...prev, isLoading: true, error: null })); try { const response await fetch(/auth/token, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code }), }); if (!response.ok) { const errorData await response.json(); throw new Error(errorData.error_description || Login failed); } const data await response.json(); const expiresAt Date.now() data.expires_in * 1000; setState(prev ({ ...prev, accessToken: data.access_token, refreshToken: data.refresh_token, idToken: data.id_token, expiresAt, isAuthenticated: true, isLoading: false, error: null, })); } catch (error) { setState(prev ({ ...prev, isLoading: false, error: error instanceof Error ? error.message : Unknown error, })); } }, []); const logout useCallback(() { setState({ accessToken: null, refreshToken: null, idToken: null, expiresAt: null, isAuthenticated: false, isLoading: false, error: null, }); localStorage.removeItem(auth_token); }, []); const refreshAccessToken useCallback(async () { if (!state.refreshToken) { throw new Error(No refresh token available); } setState(prev ({ ...prev, isLoading: true, error: null })); try { const response await fetch(/auth/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refresh_token: state.refreshToken }), }); if (!response.ok) { const errorData await response.json(); throw new Error(errorData.error_description || Token refresh failed); } const data await response.json(); const expiresAt Date.now() data.expires_in * 1000; setState(prev ({ ...prev, accessToken: data.access_token, expiresAt, isLoading: false, error: null, })); } catch (error) { setState(prev ({ ...prev, isLoading: false, error: error instanceof Error ? error.message : Unknown error, })); throw error; } }, [state.refreshToken]); return ( AuthContext.Provider value{{ state, login, logout, refreshAccessToken }} {children} /AuthContext.Provider ); } export function useAuth() { const context useContext(AuthContext); if (!context) { throw new Error(useAuth must be used within an AuthProvider); } return context; }这个useAuth的设计要点初始化逻辑分离useEffect从 localStorage 读取是独立的不和 login 流程耦合。这样即使用户手动清空 localStorage也不会导致应用崩溃。持久化时机明确只在accessToken和expiresAt同时存在时才写入 localStorage避免存入无效状态。错误处理具名化catch块里明确区分了网络错误、JSON 解析错误、业务错误并统一抛出Error实例方便上层用instanceof判断。useCallback的必要性login和refreshAccessToken都用了useCallback因为它们会被传递给子组件如LoginButton onClick{login} /。如果不包裹每次父组件 re-render子组件都会收到新函数引用触发不必要的重渲染。提示useCallback的依赖数组[state.refreshToken]是经过深思熟虑的。refreshAccessToken函数体里只用到了state.refreshToken所以只需监听它。如果写成[]函数会闭包住初始的state.refreshToken可能是 null导致后续调用永远拿不到新值如果写成[state]则每次 state 更新都会生成新函数失去useCallback的意义。3.3useMemo的正确战场消费 Token 的高性能组件现在 token 管理有了我们来看useMemo如何在消费端发挥价值。假设你有一个仪表盘页面需要根据用户权限动态渲染侧边栏菜单// components/DashboardSidebar.tsx import { useAuth } from ../hooks/useAuth; import { useMemo } from react; interface MenuItem { id: string; label: string; path: string; requiredRole: string; } const ALL_MENU_ITEMS: MenuItem[] [ { id: dashboard, label: 仪表盘, path: /dashboard, requiredRole: user }, { id: users, label: 用户管理, path: /users, requiredRole: admin }, { id: settings, label: 系统设置, path: /settings, requiredRole: admin }, ]; export function DashboardSidebar() { const { state } useAuth(); // ✅ 正确useMemo 用于纯计算输入是 token payload输出是过滤后的菜单 const visibleMenuItems useMemo(() { if (!state.accessToken) return []; try { const payload parseJwtPayload(state.accessToken) as TokenPayload; const userRoles payload.roles || []; return ALL_MENU_ITEMS.filter(item userRoles.includes(item.requiredRole) ); } catch (e) { console.warn(Failed to parse access token for menu filtering, e); return []; } }, [state.accessToken]); // 依赖 accessToken 字符串本身 return ( nav ul {visibleMenuItems.map(item ( li key{item.id} a href{item.path}{item.label}/a /li ))} /ul /nav ); }这里useMemo的价值立竿见影ALL_MENU_ITEMS是常量parseJwtPayload是纯函数无副作用filter是数组方法。整个计算过程不触发任何网络请求、不修改任何 state、不产生任何 DOM 操作。只要state.accessToken字符串没变visibleMenuItems就复用上次结果。这对于大型菜单50 项或频繁切换路由的场景能显著减少 CPU 占用。对比一下错误用法// ❌ 错误把副作用塞进 useMemo const userData useMemo(() { if (state.accessToken) { return fetchUserFromApi(state.accessToken); // 这里发请求 } return null; }, [state.accessToken]);这会导致每次state.accessToken变化时都发起一次新的fetch而且无法控制并发、无法取消、无法缓存响应。正确做法是用useEffect// ✅ 正确useEffect 处理副作用useMemo 处理计算 const [userData, setUserData] useStateUser | null(null); useEffect(() { if (!state.accessToken) return; let isMounted true; const loadUser async () { try { const data await fetchUserFromApi(state.accessToken); if (isMounted) setUserData(data); } catch (e) { if (isMounted) setUserData(null); } }; loadUser(); return () { isMounted false; }; }, [state.accessToken]); // 然后用 useMemo 处理 userData 的衍生计算 const userDisplayName useMemo(() { return userData?.name || userData?.email || 未知用户; }, [userData]);3.4 Token 刷新的自动兜底机制用useEffect监听过期最后解决那个经典问题用户开着页面一小时token 过期了点击按钮报 401体验极差。我们需要一个“后台静默刷新”机制。核心思路是在组件挂载时计算出 token 过期前 5 分钟的时间点用setTimeout设置一个刷新任务。如果用户在 5 分钟内有交互鼠标移动、键盘输入就取消并重设定时器。这比轮询更优雅比setInterval更省资源。// hooks/useAutoTokenRefresh.ts import { useEffect, useRef } from react; import { useAuth } from ./useAuth; export function useAutoTokenRefresh() { const { state, refreshAccessToken } useAuth(); const timeoutRef useRefNodeJS.Timeout | null(null); useEffect(() { // 清理上一个定时器 if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current null; } // 只有登录态且有 refreshToken 时才启用 if (!state.isAuthenticated || !state.refreshToken || !state.expiresAt) return; // 计算距离过期还有多久毫秒 const now Date.now(); const timeUntilExpiry state.expiresAt - now; // 如果已过期或即将在 5 分钟内过期则立即刷新 if (timeUntilExpiry 5 * 60 * 1000) { refreshAccessToken().catch(console.error); return; } // 设置定时器在过期前 5 分钟触发 const refreshTime state.expiresAt - 5 * 60 * 1000; const delay Math.max(0, refreshTime - now); timeoutRef.current setTimeout(() { refreshAccessToken().catch(console.error); }, delay); // 监听用户活动重置定时器 const resetTimer () { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // 重新计算再设一次 const newNow Date.now(); const newDelay Math.max(0, state.expiresAt - 5 * 60 * 1000 - newNow); timeoutRef.current setTimeout(() { refreshAccessToken().catch(console.error); }, newDelay); }; window.addEventListener(mousemove, resetTimer); window.addEventListener(keydown, resetTimer); window.addEventListener(scroll, resetTimer); return () { window.removeEventListener(mousemove, resetTimer); window.removeEventListener(keydown, resetTimer); window.removeEventListener(scroll, resetTimer); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [state.isAuthenticated, state.refreshToken, state.expiresAt, refreshAccessToken]); }这个 hook 的精妙之处在于精准的时机控制不是简单地setInterval(() refresh(), 300000)5 分钟轮询而是计算出绝对时间点避免在 token 还剩 4 分 59 秒时刷新结果 1 秒后就过期。用户行为感知通过监听mousemove/keydown/scroll确保用户活跃时才重置避免用户离开电脑后定时器还在傻等。错误隔离refreshAccessToken().catch(console.error)不会中断主流程错误被记录但不影响 UI 渲染。清理彻底useEffect的 cleanup 函数确保组件卸载时定时器和事件监听器都被移除杜绝内存泄漏。你只需要在根组件里调用它// App.tsx import { AuthProvider } from ./hooks/useAuth; import { useAutoTokenRefresh } from ./hooks/useAutoTokenRefresh; function App() { useAutoTokenRefresh(); // 一行代码全局生效 return ( AuthProvider Router {/* ... */} /Router /AuthProvider ); }4. 实操避坑指南那些让你深夜加班的 Token 错误4.1token exchange failed: token endpoint returned status 403 forbidden: country—— 地理围栏的真相这个错误不是你的代码问题而是 auth server 主动拒绝了来自特定国家/地区的请求。常见于使用 Google Identity Services、Auth0 或自建 OIDC Provider 的场景。排查步骤确认请求来源 IP在浏览器 DevTools 的 Network 面板找到/auth/token请求右键 → “Copy” → “Copy as cURL”然后在终端运行curl -v [your-curl-command]。观察响应头中的X-Geo-Location或X-Forwarded-For。检查服务器日志如果你有权限查看 auth server 的 access log搜索该请求的X-Forwarded-ForIP看是否有geo_blocked类似字段。模拟请求用 Postman 或 curl手动构造请求添加X-Forwarded-For: 8.8.8.8Google DNS看是否还报 403。如果 OK说明确实是地理围栏。解决方案前端无解这是服务端策略前端无法绕过。唯一办法是联系 auth server 运维申请白名单 IP 段或调整地理围栏规则。临时 workaround如果只是开发环境可以配置代理如 Charles Proxy修改请求头但这不能上生产。注意不要尝试用npx playwright install或其他工具去“自动化登录”这违反了大多数 SaaS 平台的 ToS服务条款且极易被风控系统识别为爬虫。4.2failed to refresh token: 400 bad request: invalid refresh_token: empty string—— 状态不同步的陷阱这个错误表明你的前端认为还有refreshToken但发给后端时却是空字符串。根本原因是前端状态和后端状态不同步。常见于以下场景用户在 Tab A 登录Tab B 同时打开Tab A 执行了logout()但 Tab B 的state.refreshToken还是旧值refreshToken被后端主动废止如用户修改密码但前端没收到通知localStorage被其他脚本如广告 SDK意外清空。防御性编程实践在refreshAccessToken函数开头强制校验const refreshAccessToken useCallback(async () { // 强制双重校验 if (!state.refreshToken || state.refreshToken.trim() ) { console.warn(Refresh token is empty or invalid, triggering full logout); logout(); throw new Error(Refresh token missing); } // ... rest of the logic }, [state.refreshToken, logout]);同时在logout函数里不仅要清空state还要清空所有可能的存储位置const logout useCallback(() { // 清空所有已知的 token 存储位置 localStorage.removeItem(auth_token); sessionStorage.removeItem(auth_token); // 如果用了 cookies也要清除需后端配合 document.cookie.split(;).forEach(function(c) { document.cookie c.replace(/^ /, ).replace(/.*/, ;expires new Date().toUTCString() ;path/); }); setState({ /* ... */ }); }, []);4.3sign-in could not be completed token exchange failed: error sending request—— CORS 与 Fetch 配置详解这个错误通常伴随TypeError: Failed to fetch本质是浏览器阻止了跨域请求。error sending request是 fetch 的底层错误不是后端返回的 HTTP 错误。关键配置检查清单配置项正确值错误示例为什么重要modecorsno-corsno-cors会禁用大部分响应头读取且无法获取403等错误详情credentialsincludeomit登录需要携带 cookie如 CSRF tokenomit会丢弃 cookieheaders[Content-Type]application/jsontext/plain后端可能只接受application/json否则返回415 Unsupported Media Typeheaders[Accept]application/json未设置有些 auth server 要求明确Accept头完整 fetch 示例const response await fetch(/auth/token, { method: POST, mode: cors, // 必须 credentials: include, // 必须如果后端用 session headers: { Content-Type: application/json, Accept: application/json, // 如果后端要求 CSRF token这里也要加 // X-CSRF-Token: getCsrfTokenFromCookie() }, body: JSON.stringify({ code }), });后端必须配合的 CORS 头Access-Control-Allow-Origin: https://your-frontend-domain.com Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: Content-Type, Accept, X-CSRF-Token提示Access-Control-Allow-Origin不能是*当credentials: include时必须指定具体域名。4.4your access token could not be refreshed because you have since logged out—— 会话终结的优雅处理这个错误意味着你的refreshToken已被后端作废但前端还在尝试用它刷新。常见于单点登录SSO场景用户在另一个 Tab 或设备上登出了。最佳实践后端返回明确的错误码要求后端在refreshToken作废时返回401 Unauthorized或自定义498 Invalid Token而不是400 Bad Request。前端统一拦截在 axios interceptor 或自定义 fetch wrapper 中捕获401响应自动 dispatchLOGOUTaction// utils/apiClient.ts export const apiClient axios.create({ baseURL: /api, }); apiClient.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { // 触发全局登出 store.dispatch({ type: LOGOUT }); // 跳转登录页 window.location.href /login?redirect encodeURIComponent(window.location.pathname); } return Promise.reject(error
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →