☰
React Token 管理:从 caveman 调试到可审计认证状态
2026/10/29 15:30:54 网站建设 项目流程

1. “caveman”不是远古人,是 React 开发者自嘲式技术隐喻

最近在几个前端技术群和 React 相关的 PR 评论区里,“caveman”这个词突然高频出现——它既不是某个新出的 UI 库名,也不是某家初创公司的代号,更不是某个 npm 包的正式发布名。它是一个带着黑色幽默色彩的内部梗,专指一类特定的 React 开发实践状态:在复杂状态管理、Token 生命周期处理、身份认证链路调试中,退化到“只靠 console.log + 刷新页面 + 硬重启”三板斧解决问题的原始阶段。你可能刚遇到sign-in could not be completed token exchange failed: error sending request,翻了 7 个 Stack Overflow 页面,试了 3 种npx playwright install的变体,最后发现是.env文件里少了个换行符;也可能在写useMemo时反复纠结依赖数组该不该加token,结果绕了一大圈才发现问题根本不在 memo,而在authContext的初始值没兜住空 token 场景——这种“靠直觉、凭运气、拼手速”的开发方式,就被同行戏称为“caveman mode”。

这个词精准戳中了当前 React 生态中一个普遍却少被系统讨论的痛点:当项目规模越过临界点,身份认证(尤其是基于 JWT 的 Token 流程)与 React 的声明式、组件化范式之间,开始产生结构性摩擦。token不再是简单的字符串,而是牵一发而动全身的状态核心;useMemo不再只是性能优化工具,而成了防止 Token 无效时触发无限重渲染的救命稻草;npx命令失败背后,往往暴露的是本地环境、CI/CD 配置、网络策略与认证服务端响应逻辑的多重耦合。而所谓“caveman”,本质上是在这套复杂系统失序时,开发者被迫退回最基础、最不可靠但短期最“有效”的调试本能。它不指向技术能力缺陷,而指向工程设计断层——当React的抽象层无法平滑承接token exchange failed: token endpoint returned status 403 forbidden: country这类真实世界错误时,人就只能赤手空拳上阵。

这篇文章要做的,不是教你如何优雅地写一个useAuthHook,而是带你完整复盘一次典型的“caveman 事件”:从一条报错日志出发,逆向拆解整个 Token 认证链路在 React 应用中的落地细节,解释为什么npx playwright install失败会和token endpoint returned 403产生隐性关联,为什么useMemo的依赖项漏掉一个token就会让画布(canvas/flowork)白屏数分钟,以及如何把这种“原始调试”过程,系统性地升级为可复现、可测试、可监控的现代前端工程实践。适合正在维护中大型 React 项目、频繁遭遇登录态异常、Token 刷新失败、跨域认证问题的工程师,也适合刚从 Vue/Angular 转来、对 React 生态中“状态即一切”哲学尚不适应的开发者。你不需要精通 OAuth2.0 RFC,但得愿意跟着一行console.log深挖到底。

2. 项目整体设计思路:从“报错即崩溃”到“Token 即状态”

2.1 为什么“caveman”模式在 React 中如此顽固?

很多团队在初期用create-react-app搭建登录页时,会写一个非常干净的login()函数:

const login = async (credentials) => { const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(credentials) }); const { token } = await res.json(); localStorage.setItem('auth_token', token); setUser({ token }); };

这看起来毫无问题。但当项目加入React Router v6的受保护路由、集成Playwright做 E2E 测试、接入Claude MCP servers类 AI 服务、或需要支持多地域用户(触发country级别的 403)时,这个简单模型立刻崩塌。根本原因在于:React 的核心范式是“状态驱动 UI”,而传统 Token 管理方案(如直接操作 localStorage)是“命令式副作用”,二者天然存在范式冲突。

  • localStorage.setItem是一个不可回溯、不可撤销、无生命周期感知的副作用;
  • useMemo依赖数组若漏掉token,会导致缓存失效或无限循环,因为token变化本应触发整个认证上下文重计算;
  • npx playwright install失败常因网络策略拦截了https://registry.npmjs.org的请求,而这恰好也是token exchange请求被 403 的同一网络出口——它们共享底层 HTTP 客户端配置,却在代码层面完全隔离。

因此,“caveman”模式的本质,是开发者在范式冲突面前,选择了放弃抽象、回归原始:不再信任 Hook 的依赖追踪,改用window.location.reload()强刷;不再信任fetch的错误边界,改用try/catch + alert();不再信任 CI 环境的npx,改用本地npm install -g playwright后手动拷贝二进制。这不是懒惰,而是对现有抽象层失效的务实响应。

2.2 我们的设计原则:让 Token 成为一级公民状态

要终结“caveman”,必须将 Token 从“存储介质中的字符串”升格为“React 应用中的一等状态”。这意味着:

  1. Token 必须有明确的生命周期状态机:idle→loading→authenticated→refreshing→expired→unauthorized,每个状态对应清晰的 UI 行为和副作用;
  2. 所有 Token 相关操作必须可中断、可重试、可审计:npx playwright install失败时,应能定位到具体哪个 registry URL 被拦截;token exchange failed时,应能区分是网络超时、403 Forbidden 还是 400 Bad Request;
  3. 状态变更必须严格遵循 React 的更新规则:useMemo的依赖数组必须包含所有影响其输出的变量,包括token、userRole、permissions等衍生状态;
  4. 环境隔离必须显式声明:开发、测试、生产环境的token endpoint、playwright browserPath、AI service base URL必须通过process.env显式注入,而非硬编码或动态拼接。

这套设计不追求“最优雅”,而追求“最可调试”。当sign-in could not be completed时,你打开 DevTools,看到的不是一个模糊的Network Error,而是一个结构化的AuthState对象,其中error.code === 'TOKEN_EXCHANGE_403',error.context.country === 'CN',error.timestamp精确到毫秒——这才是现代前端工程该有的样子,而不是靠console.log('token?', token)猜测。

2.3 架构选型:轻量级但绝不妥协的组合

我们拒绝引入Redux Toolkit Query或SWR这类重型数据流库,原因很实际:Token 认证是应用的基石,不应依赖可能被误配或误用的通用数据层。我们的技术栈选择基于三个硬性标准:零运行时开销、最小 API 表面、与 React 18+ 并发渲染兼容。

  • 状态管理:原生useReducer+useContext。useReducer提供可预测的状态变迁,useContext解决跨层级传递,二者组合比useState更易追踪 Token 状态流转。我们定义了一个AuthReducer,其action.type严格对应 RFC 6749 的授权码流程(Authorization Code Flow)各环节:AUTH_INIT、AUTH_CODE_RECEIVED、TOKEN_EXCHANGE_STARTED、TOKEN_EXCHANGE_SUCCESS、TOKEN_EXCHANGE_FAILED、TOKEN_REFRESHED、LOGOUT。
  • HTTP 客户端:fetch封装,而非axios。fetch是浏览器原生 API,无额外包体积,且其AbortController天然支持请求取消——这对token exchange这种敏感操作至关重要。我们封装的authFetch函数会自动注入Authorization: Bearer ${token},并在401时触发刷新逻辑,在403时记录国家上下文。
  • 持久化:localStorage+ 内存缓存双写。localStorage保证页面刷新后 Token 不丢失,内存缓存(一个RefObject)保证组件内快速读取,避免重复解析 JWT。关键点在于:所有写入localStorage的操作,都必须伴随dispatch({ type: 'TOKEN_PERSISTED', payload: token }),确保状态同步。
  • 测试与调试:Playwright+msw(Mock Service Worker)。npx playwright install失败?我们用msw模拟token endpoint的各种响应(200、403、timeout),在 CI 中跳过真实安装,直接运行测试。这样npx playwright install的失败就从“阻塞构建”降级为“本地开发提示”,不影响主干交付。

这个架构没有炫技,但它让每一个token相关的错误,都能被精确定位到 reducer 的某一行case,被msw的某一个handler捕获,被useMemo的某一个依赖项触发——这才是对抗“caveman”的真正武器。

3. 核心细节解析:Token 生命周期与 React 状态的精密咬合

3.1 Token 状态机详解:从idle到unauthorized的七步演进

一个健壮的 Token 管理系统,其核心是一个明确定义的状态机。我们定义的AuthState接口如下:

type AuthState = { status: 'idle' | 'loading' | 'authenticated' | 'refreshing' | 'expired' | 'unauthorized' | 'error'; token: string | null; user: User | null; error: { code: string; // 如 'TOKEN_EXCHANGE_403', 'REFRESH_TOKEN_EMPTY' message: string; context: Record<string, any>; // 如 { country: 'CN', url: 'https://auth.openai.co' } timestamp: number; } | null; expiresAt: number | null; // JWT exp 时间戳 };

这个状态机不是理论模型,而是每一帧都在 React 中真实运行的实体。它的变迁由AuthReducer驱动,而AuthReducer的dispatch调用,全部来自AuthContext提供的login()、logout()、refreshToken()等方法。关键细节在于:

  • idle状态的陷阱:很多项目在 App 初始化时,直接dispatch({ type: 'AUTH_INIT' }),然后检查localStorage.getItem('auth_token')。但这忽略了idle状态下,UI 可能已开始渲染(如导航栏的userAvatar组件),而token还未加载完成。正确做法是:idle状态下,token和user必须为null,且status明确为'idle',UI 组件据此显示加载骨架屏,而非尝试渲染空用户信息。
  • loading与refreshing的严格区分:loading仅用于首次登录或页面刷新后的 Token 恢复;refreshing专用于后台静默刷新(Silent Refresh)。二者 UI 表现不同:loading时全屏遮罩,refreshing时仅禁用相关按钮。混淆二者会导致用户点击“提交”后,因 Token 正在刷新而被意外拦截。
  • expired与unauthorized的语义分离:expired指 Token 的 JWTexp字段已过期,但用户仍处于登录态(refresh_token有效);unauthorized指refresh_token也已失效或被吊销,必须强制登出。很多项目将二者合并为loggedOut,导致无法区分“用户只是离开太久”和“账号已被管理员禁用”这两种完全不同场景。

提示:expiresAt字段必须从 JWT 的exp字段解析,而非依赖服务端返回的expires_in秒数。因为expires_in是相对时间,而exp是绝对时间戳,后者能规避客户端时钟偏差问题。我们用jwt-decode库解析,但只取exp,不验证签名——签名验证是服务端责任,前端只需信任token的exp字段。

3.2useMemo在 Token 场景下的致命陷阱与安全写法

useMemo是 React 中最常被误用的 Hook 之一,尤其在涉及 Token 的场景。一个典型错误是:

// ❌ 危险!依赖数组漏掉了 token const canvasConfig = useMemo(() => { return { width: 800, height: 600, backgroundColor: user?.theme || 'white', // ... 其他配置 }; }, [user?.theme]); // 错!user.theme 可能不变,但 token 变了,user 对象引用却没变!

问题在于:user对象通常由useContext(AuthContext)获取,其引用在token更新时并不一定改变(如果user数据来自缓存或未重新解析)。useMemo的依赖数组只看引用是否变化,不深比较对象内容。结果就是:token已刷新,user角色权限已变,但canvasConfig缓存未更新,导致画布(flowork)渲染旧权限下的内容,甚至白屏。

正确写法必须将所有影响输出的变量显式列出:

// ✅ 安全!token 和 user 都是独立依赖 const canvasConfig = useMemo(() => { return { width: 800, height: 600, backgroundColor: user?.theme || 'white', permissions: user?.permissions || [], // ... 其他配置 }; }, [token, user?.theme, user?.permissions]); // token 是关键!即使 user 没变,token 变了也要重算 // ✅ 更优!用一个稳定引用封装所有依赖 const configDeps = useMemo(() => ({ token, theme: user?.theme, permissions: user?.permissions, }), [token, user?.theme, user?.permissions]); const canvasConfig = useMemo(() => ({ width: 800, height: 600, backgroundColor: configDeps.theme || 'white', permissions: configDeps.permissions || [], }), [configDeps]);

第二个方案的优势在于:configDeps是一个稳定的对象引用,只要其内部属性变化,configDeps就会是新对象,canvasConfig的依赖数组[configDeps]就能可靠触发重计算。这避免了在多个useMemo中重复写长依赖数组。

注意:token本身是一个字符串,其引用变化即表示 Token 已更新。不要试图用JSON.stringify(token)或其他方式“标准化”它——那只会增加不必要的计算开销,且string的引用比较是最高效的。

3.3npx失败与token exchange failed的深层关联

npx playwright install失败和token exchange failed: error sending request看似风马牛不相及,但在企业级网络环境中,它们共享同一个底层约束:出站 HTTP 请求的代理与防火墙策略。

  • npx playwright install的本质是:npx会从https://registry.npmjs.org下载playwright包元数据,然后根据PLAYWRIGHT_DOWNLOAD_HOST环境变量(默认https://npmmirror.com/mirrors/playwright)下载浏览器二进制文件。整个过程涉及多次 HTTPS 请求。
  • token exchange请求则发送到https://auth.openai.co(或其他厂商的 auth endpoint),同样是一次 HTTPS POST 请求。

当公司网络策略启用以下任一措施时,二者会同时失败:

策略类型影响npx影响token exchange调试线索
域名白名单registry.npmjs.org未在白名单auth.openai.co未在白名单npx报ENOTFOUND,fetch报TypeError: Failed to fetch
HTTPS 拦截(SSL Inspection)npx证书校验失败fetch证书校验失败npx报CERT_HAS_EXPIRED,fetch报net::ERR_CERT_INVALID
国家/地区封锁npmmirror.com的 CDN 节点被限auth.openai.co返回403 Forbidden: countrynpx下载缓慢或超时,fetch返回403且响应头含X-Country: CN

因此,当npx playwright install失败时,第一反应不应该是“重装 Node.js”,而应执行:

# 检查网络连通性 curl -v https://registry.npmjs.org curl -v https://auth.openai.co # 检查 DNS 解析 nslookup registry.npmjs.org nslookup auth.openai.co # 检查代理设置(关键!) echo $HTTP_PROXY $HTTPS_PROXY npm config get proxy npm config get https-proxy

如果发现curl也失败,说明是网络策略问题,需联系 IT 部门开通白名单;如果curl成功而npx失败,则是npx的缓存或权限问题;如果curl对auth.openai.co返回403,则token exchange失败原因已锁定——此时前端代码无需修改,只需后端配置CORS或调整地域策略。

实操心得:我在某金融客户项目中,曾花 3 天排查token exchange failed,最终发现是HTTPS_PROXY环境变量被 CI 系统全局注入,而该代理服务器不支持auth.openai.co的 SNI 扩展。解决方案是:在 CI 脚本中unset HTTPS_PROXY,并为playwright单独配置PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright。这比改任何一行 React 代码都有效。

4. 实操过程:从零搭建可调试的 Token 认证系统

4.1 环境准备与依赖安装:避开npx陷阱的务实方案

第一步不是写代码,而是确保你的本地和 CI 环境能稳定运行。npx playwright install失败是常见拦路虎,但我们有更可靠的替代路径:

  1. 跳过npx,直接使用npm全局安装 Playwright:

    # 全局安装,避免 npx 的路径解析问题 npm install -g playwright # 手动安装浏览器(指定国内镜像) PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright npx playwright install chromium firefox webkit
  2. 为 CI/CD 环境预设环境变量(以 GitHub Actions 为例):

    # .github/workflows/test.yml jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies run: npm ci - name: Install Playwright browsers # 关键:跳过 npx,直接调用 playwright CLI run: npx playwright install --with-deps chromium - name: Run tests run: npx playwright test
  3. 配置.npmrc统一 registry(避免npx和npm install使用不同源):

    registry=https://registry.npmjs.org/ @scope:registry=https://registry.npmjs.org/ //registry.npmjs.org/:_authToken=${NPM_TOKEN} # 国内加速(可选) # registry=https://registry.npmmirror.com/

注意:NPM_TOKEN是私有包发布的凭证,与auth token无关。不要混淆二者。git 设置代码库token用于 Git 操作,hass g10s token用于 Home Assistant 集成,它们都是独立的认证体系,共用“token”一词只是语义巧合。

4.2AuthContext实现:一个可调试、可测试的认证上下文

以下是AuthContext的核心实现,重点在于其可调试性:

// src/context/AuthContext.tsx import { createContext, useContext, useReducer, useEffect, useRef } from 'react'; import { jwtDecode } from 'jwt-decode'; export interface User { id: string; email: string; role: string; permissions: string[]; } export interface AuthState { status: 'idle' | 'loading' | 'authenticated' | 'refreshing' | 'expired' | 'unauthorized' | 'error'; token: string | null; user: User | null; error: { code: string; message: string; context: Record<string, any>; timestamp: number; } | null; expiresAt: number | null; } type AuthAction = | { type: 'AUTH_INIT' } | { type: 'AUTH_LOADING' } | { type: 'AUTH_SUCCESS'; payload: { token: string; user: User } } | { type: 'AUTH_ERROR'; payload: { code: string; message: string; context: Record<string, any> } } | { type: 'TOKEN_REFRESHING' } | { type: 'TOKEN_REFRESHED'; payload: { token: string; user: User } } | { type: 'LOGOUT' }; const initialState: AuthState = { status: 'idle', token: null, user: null, error: null, expiresAt: null, }; // 一个 Ref,用于在 reducer 外部访问最新 state(调试用) const authStateRef = { current: initialState }; const AuthReducer = (state: AuthState, action: AuthAction): AuthState => { let newState: AuthState; switch (action.type) { case 'AUTH_INIT': newState = { ...state, status: 'loading' }; break; case 'AUTH_LOADING': newState = { ...state, status: 'loading', error: null }; break; case 'AUTH_SUCCESS': const decoded = jwtDecode<{ exp: number }>(action.payload.token); newState = { ...state, status: 'authenticated', token: action.payload.token, user: action.payload.user, error: null, expiresAt: decoded.exp * 1000, // 转为毫秒 }; break; case 'AUTH_ERROR': newState = { ...state, status: 'error', error: { code: action.payload.code, message: action.payload.message, context: action.payload.context, timestamp: Date.now(), }, }; break; case 'TOKEN_REFRESHING': newState = { ...state, status: 'refreshing', error: null }; break; case 'TOKEN_REFRESHED': const refreshedDecoded = jwtDecode<{ exp: number }>(action.payload.token); newState = { ...state, status: 'authenticated', token: action.payload.token, user: action.payload.user, error: null, expiresAt: refreshedDecoded.exp * 1000, }; break; case 'LOGOUT': newState = { ...initialState, status: 'idle', }; break; default: newState = state; } // 更新 ref,便于外部调试 authStateRef.current = newState; return newState; }; const AuthContext = createContext<{ state: AuthState; login: (credentials: { email: string; password: string }) => Promise<void>; logout: () => void; refreshToken: () => Promise<void>; }>({ state: initialState, login: async () => {}, logout: () => {}, refreshToken: async () => {}, }); export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const [state, dispatch] = useReducer(AuthReducer, initialState); // 初始化:从 localStorage 恢复 token useEffect(() => { const savedToken = localStorage.getItem('auth_token'); if (savedToken) { try { const decoded = jwtDecode<{ exp: number }>(savedToken); if (decoded.exp * 1000 > Date.now()) { // Token 未过期,但需验证有效性(调用 /me 或类似 endpoint) dispatch({ type: 'AUTH_LOADING' }); // 这里应调用 authFetch('/api/me', { method: 'GET' }) } else { dispatch({ type: 'AUTH_ERROR', payload: { code: 'TOKEN_EXPIRED', message: 'Token expired', context: {} } }); } } catch (e) { dispatch({ type: 'AUTH_ERROR', payload: { code: 'TOKEN_INVALID', message: 'Invalid token format', context: { error: (e as Error).message } } }); } } else { dispatch({ type: 'AUTH_INIT' }); } }, []); const login = async (credentials: { email: string; password: string }) => { try { dispatch({ type: 'AUTH_LOADING' }); const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errorData = await response.json(); throw new Error(`Login failed: ${errorData.message || response.status}`); } const data = await response.json(); const { token } = data; // 解析用户信息 const decoded = jwtDecode<{ sub: string; email: string; role: string; permissions: string[] }>(token); localStorage.setItem('auth_token', token); dispatch({ type: 'AUTH_SUCCESS', payload: { token, user: { id: decoded.sub, email: decoded.email, role: decoded.role, permissions: decoded.permissions || [], }, }, }); } catch (error) { dispatch({ type: 'AUTH_ERROR', payload: { code: 'LOGIN_FAILED', message: (error as Error).message, context: { credentials: { email: '***' } }, }, }); } }; const logout = () => { localStorage.removeItem('auth_token'); dispatch({ type: 'LOGOUT' }); }; const refreshToken = async () => { const refreshToken = localStorage.getItem('refresh_token'); // 假设有 refresh_token if (!refreshToken) { dispatch({ type: 'AUTH_ERROR', payload: { code: 'REFRESH_TOKEN_EMPTY', message: 'Refresh token is empty', context: {}, }, }); return; } try { dispatch({ type: 'TOKEN_REFRESHING' }); const response = await fetch('/api/token/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(`Refresh failed: ${errorData.message || response.status}`); } const data = await response.json(); const { token } = data; const decoded = jwtDecode<{ sub: string; email: string; role: string; permissions: string[] }>(token); localStorage.setItem('auth_token', token); dispatch({ type: 'TOKEN_REFRESHED', payload: { token, user: { id: decoded.sub, email: decoded.email, role: decoded.role, permissions: decoded.permissions || [], }, }, }); } catch (error) { dispatch({ type: 'AUTH_ERROR', payload: { code: 'TOKEN_REFRESH_FAILED', message: (error as Error).message, context: {}, }, }); } }; return ( <AuthContext.Provider value={{ state, login, logout, refreshToken }}> {children} </AuthContext.Provider> ); }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }; // 调试用:导出当前状态 ref export const getAuthState = () => authStateRef.current;

这个实现的关键优势:

  • authStateRef:允许你在任意地方(如浏览器控制台)调用getAuthState()查看最新状态,无需依赖 React DevTools;
  • useEffect初始化逻辑:明确区分idle和loading,避免 UI 闪动;
  • 错误context字段:{ credentials: { email: '***' } }这样的脱敏记录,既保留调试信息,又符合安全规范;
  • jwtDecode的轻量使用:只解析exp和必要字段,不进行签名验证,符合前端职责边界。

4.3authFetch封装:让每一次 Token 请求都可审计

fetch是基础,但裸fetch无法满足 Token 场景的特殊需求。我们封装authFetch,使其具备:

  • 自动注入Authorizationheader;
  • 自动处理401(触发刷新)和403(记录国家上下文);
  • 请求取消支持(AbortController);
  • 响应时间监控;
  • 错误分类(网络错误 vs HTTP 错误)。
// src/utils/authFetch.ts import { getAuthState, useAuth } from '../context/AuthContext'; interface AuthFetchOptions extends RequestInit { timeout?: number; // ms } export const authFetch = async ( input: string | URL, options: AuthFetchOptions = {} ): Promise<Response> => { const state = getAuthState(); // 使用 ref,避免闭包 stale state const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000); // 自动添加 Authorization header const headers = new Headers(options.headers); if (state.token && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${state.token}`); } try { const response = await fetch(input, { ...options, headers, signal: controller.signal, }); clearTimeout(timeoutId); // 特殊 HTTP 状态处理 if (response.status === 401) { // 触发刷新逻辑(此处应调用 useAuth().refreshToken(),但需注意 Hook 规则) // 实际项目中,这里会抛出一个自定义错误,由上层组件捕获并调用 refreshToken throw new Error('Unauthorized: token expired or invalid'); } if (response.status === 403) { // 记录 403 上下文,如国家信息 const country = response.headers.get('X-Country') || 'UNKNOWN'; throw new Error(`Forbidden: access denied for country ${country}`); } return response; } catch (error) { clearTimeout(timeoutId); if (error instanceof DOMException && error.name === 'AbortError') { throw new Error('Request timeout'); } throw error; } }; // 使用示例 const loadData = async () => { try { const response = await authFetch('/api/data', { timeout: 5000 }); return await response.json(); } catch (error) { console.error('Failed to load data:', error); // 这里可以 dispatch 一个全局错误,或显示 toast } };

实操心得:authFetch的timeout参数必须显式设置。默认fetch无超时,当token endpoint因网络策略被拦截时,请求会永远挂起,导致 UI “假死”。5 秒超时是经验阈值——足够完成大多数认证请求,又能在异常时快速失败。

4.4ProtectedRoute与useAuth的实战整合

最后,将认证状态与路由、组件深度整合:

// src/components/ProtectedRoute.tsx import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; export const ProtectedRoute = () => { const { state } = useAuth(); // 状态映射到 UI 行为 switch (state.status) { case 'idle': // 初始加载,显示骨架屏 return <div>Loading...</div>; case 'loading': // 正在恢复 Token,显示加载指示器 return <div>Authenticating...</div>; case 'authenticated': // 正常渲染 return <Outlet />; case 'refreshing': // 静默刷新中,允许继续浏览,但禁用敏感操作 return <Outlet />; case 'expired': // Token 过期,尝试刷新 // (实际中,这里会触发 refreshToken) return <div>Refreshing session...</div>; case 'unauthorized': // 强制登出 return <Navigate to="/login" replace />; case 'error': // 显示结构化错误 return ( <div> <h2>Authentication Error</h2> <p>Code: {state.error?.code}</p> <p>Message: {state.error?.message}</p> {state.error?.context.country && ( <p>Country: {state.error.context.country}</p> )} <button onClick={() => window.location.reload()}> Retry </button> </div> ); default: return <Navigate to="/login" replace />; } }; // src/App.tsx import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import { ProtectedRoute } from './components/ProtectedRoute

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询