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 useCaveman<T>(value: T): T { const ref = useRef<T>(value); if (!Object.is(ref.current, value)) { ref.current = value; } return ref.current; }你看,它连useMemo都没用,直接用useRef+Object.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):单独抽离,失败时抛出明确错误类型(如TokenRefreshFailedError);clearToken():同步清除 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构建成树形结构; - 对大型列表数据做
filter+sort后的分页切片。
这些操作的特点是: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_description;401时是否要清空本地 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,而是用useState+useEffect,因为我们要精确控制 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) => Promise<void>; logout: () => void; refreshAccessToken: () => Promise<void>; } | null>(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState<TokenState>({ 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] = useState<User | 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 = useRef<NodeJS.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.8(Google 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 错误。
关键配置检查清单:
| 配置项 | 正确值 | 错误示例 | 为什么重要 |
|---|---|---|---|
mode | 'cors' | 'no-cors' | 'no-cors'会禁用大部分响应头读取,且无法获取403等错误详情 |
credentials | 'include' | 'omit' | 登录需要携带 cookie(如 CSRF token),'omit'会丢弃 cookie |
headers['Content-Type'] | 'application/json' | 'text/plain' | 后端可能只接受application/json,否则返回415 Unsupported Media Type |
headers['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