1. 项目概述:这不是一个“原始人”主题的复古玩具,而是一套面向现代 React 开发者的轻量级状态协调与副作用管理工具集
“caveman”这个标题乍看像在讲石器时代,但结合当前 React 生态中高频出现的token、npx、useMemo等关键词,以及大量开发者在登录鉴权、API 通信、本地调试中反复遭遇的token exchange failed: token endpoint returned status 403 forbidden、sign-in could not be completed、your access token could not be refreshed等报错,就能立刻意识到——这根本不是考古项目,而是一个极具现实痛感的工程化命名:用最朴素、最可控、最不依赖外部服务的方式,把“身份凭证”(token)这件事,拉回开发者自己手里,像远古人类掌控火种一样,亲手点燃、维持、传递、熄灭。它不追求炫技的 AI Agent 框架图,也不堆砌 React 18+ 的新 Hook 套路,而是聚焦一个被严重低估的基础事实:绝大多数前端崩溃,不是出在算法逻辑,而是出在 token 生命周期失控导致的连锁失效。“caveman” 就是那个拒绝把火种交给神庙祭司(即第三方认证中心)、坚持自己钻木取火的开发者。它适合所有正在用 React 构建需要用户登录、API 调用、权限校验的中后台系统、SaaS 工具或内部平台的工程师——尤其是那些刚被403 Forbidden报错折磨得凌晨三点还在翻 OpenAI 文档、GitLab 日志、Auth0 配置页的人。它不提供“一键解决”,但会给你一套可审计、可打断、可单步调试的 token 管理骨架,让你从“token 失效焦虑症”患者,变成 token 生命周期的主权持有者。
2. 核心设计思路拆解:为什么选择“原始人”哲学?——拒绝黑盒,拥抱显式控制流
2.1 不是框架,是“契约协议”:caveman 的本质定位
很多开发者第一反应是:“caveman 是不是又一个 React 状态管理库?”答案是否定的。它甚至不提供createStore或useReducer这类抽象。它的核心文件结构极其简单:一个tokenManager.ts(或.js),一个authService.ts,外加几行npx初始化脚本。它的哲学根基,来自对当前主流方案的三重反思:
对“自动续签”的祛魅:
jwt实现 token 续签 的教程满天飞,但真实场景中,refresh_token本身也会过期、被吊销、受 IP/设备限制。当axios.interceptors.response.use捕获到 401 后触发refreshToken(),结果refreshToken()又返回 403(country限制、logged out、token endpoint returned status 403 forbidden),整个链路就陷入死循环。caveman 的立场是:续签不是魔法,而是明确的用户意图动作。它不自动发起续签请求,而是暴露canRefresh()和triggerRefresh()两个函数,由业务层决定——是在用户点击“保持登录”按钮时调用,还是在 token 剩余有效期 < 5 分钟时弹窗询问,抑或干脆不续签,直接跳转登录页。这种“显式性”,让调试变得可能:你一眼就能在 DevTools 的console.log中看到“[caveman] refresh triggered by user action”,而不是在一堆Promise.allSettled的嵌套里追踪一个消失的refresh_token请求。对“全局拦截器”的警惕:
React 面经里常考“如何用 axios 拦截器统一处理 token”,但线上问题排查时,你会发现interceptor的执行顺序、错误捕获边界、与Suspense/ErrorBoundary的交互,全是坑。react native 启动白屏很多时候就源于拦截器里一个未被捕获的localStorage.getItem('token')抛出异常,阻塞了整个渲染。caveman 的方案是“去拦截器化”:它不修改任何网络请求库的行为,而是要求每个需要 token 的 API 调用,必须显式调用getToken()。这个函数内部会做三件事:检查本地存储、验证有效期、必要时触发刷新逻辑。看起来多写了一行代码,但换来的是完全透明的依赖关系——fetchUserProfile()函数的签名清晰写着async function fetchUserProfile(token: string),你一眼就知道它依赖什么,测试时也只需 mock 一个字符串,而非 mock 整个 axios 实例。对“npx 即服务”的务实采纳:
npx playwright install失败、npx create-react-app等报错,本质是开发者对“命令行工具链”稳定性的焦虑。caveman 并不排斥npx,相反,它把npx当作一个可验证、可审计、无副作用的初始化入口。它的npx caveman init脚本,只做三件事:1)检查当前项目是否为标准 React 项目(package.json中有react和react-dom);2)在src/lib/下创建tokenManager.ts和authService.ts两个文件,内容是经过严格测试的最小可行代码;3)输出一份README.md片段,说明如何在App.tsx中集成。它不做npm install --save-dev caveman,因为那意味着引入一个未知版本的第三方包,其内部可能偷偷调用fetch去某个https://auth.openai.co端点(这正是token exchange failed: error sending request for url (https://auth.openai.co的根源)。npx的优势在于:代码就在你本地,node_modules/.bin/caveman是一个指向caveman-cli的符号链接,而caveman-cli的源码你随时可以git clone下来审计——这符合“原始人”对火种的绝对掌控欲。
2.2 useMemo 的深度绑定:不是性能优化,而是状态一致性锚点
useMemo在caveman中的使用,是其设计中最精妙的一环,也是最容易被误解为“过度优化”的地方。网上很多React 面试题会问“useMemo和useCallback的区别”,但很少有人指出:useMemo是 React 中唯一能将“计算结果”与“依赖数组的引用稳定性”进行强绑定的 Hook。在 token 管理场景下,这解决了核心矛盾:token字符串本身是不可变的(primitive),但它的“有效性”是动态的(取决于时间戳、网络状态、用户操作)。caveman的核心 hookuseTokenState()内部实现如下:
// src/lib/tokenManager.ts import { useState, useMemo, useEffect } from 'react'; export function useTokenState() { const [rawToken, setRawToken] = useState<string | null>(null); const [isRefreshing, setIsRefreshing] = useState(false); // 关键:useMemo 的依赖数组包含 rawToken 和 isRefreshing // 这意味着:只要 rawToken 或 isRefreshing 改变,memoizedToken 必须重新计算 // 而 memoizedToken 的计算逻辑,就是执行完整的 token 验证流程 const memoizedToken = useMemo(() => { if (!rawToken) return null; try { const payload = JSON.parse(atob(rawToken.split('.')[1])); const now = Date.now() / 1000; // 显式检查过期时间,而非依赖 localStorage 的“存在性” if (payload.exp < now) { console.warn('[caveman] Token expired at', new Date(payload.exp * 1000)); return null; } // 还可以加入自定义校验,如检查 payload.aud 是否匹配预期 audience if (payload.aud !== 'my-app') { console.error('[caveman] Token audience mismatch:', payload.aud); return null; } return rawToken; } catch (e) { console.error('[caveman] Failed to parse token:', e); return null; } }, [rawToken, isRefreshing]); // 注意:isRefreshing 作为依赖,强制在刷新状态变化时重新验证 // ... 其他逻辑,如 getToken()、setToken()、clearToken() }这段代码的价值,在于它把“token 是否有效”这个业务逻辑判断,变成了一个纯函数式的、可预测的、与组件渲染生命周期强同步的计算过程。memoizedToken的值,永远是你当前rawToken状态和isRefreshing状态下的最新、最准确的有效性结论。它不像useState那样可能因异步更新而滞后,也不像useEffect那样有延迟。当你在组件中const token = useTokenState().getToken();时,你拿到的不是一个可能已过期的缓存字符串,而是经过useMemo实时计算、并确保与当前组件状态完全一致的权威结果。这就是为什么caveman能避免react 画布 flowork中常见的“画布已渲染,但 token 已失效,导致后续 API 调用全 401”的竞态问题——因为画布组件的useMemo计算,和它的useEffect数据获取,共享同一个memoizedToken的计算时机。
2.3 与热词生态的精准切割:为什么它不解决“country”限制或“agent=15”问题?
网络搜索中大量出现的token exchange failed: token endpoint returned status 403 forbidden: country、https://2026092103.dasongsp.xyz/?token=zxzrpy+cdxiaq1tifrvzwa==&agent=15等链接,指向一个残酷现实:很多 token 失效,根源不在前端代码,而在服务端策略或网络环境。caveman的设计哲学,是清晰划定责任边界。它不试图去“绕过”country限制,因为那属于服务端风控策略,前端强行模拟地理位置或代理请求,既违法又脆弱。它也不解析&agent=15这样的 query 参数,因为那是特定后端服务的私有协议,caveman的目标是通用性。它的应对策略是:将这些外部失败,转化为前端可理解、可响应、可降级的明确状态。当authService.exchangeCodeForToken()调用返回403时,caveman不会静默失败,而是:
- 分类记录:在
console.error中输出[caveman] Token exchange failed with status 403. Response body: {...},并附上完整的响应体(如果可读)。 - 状态映射:根据响应体中的
message字段(如"current link download file when get token is empty"),将其映射为一个预定义的CavemanErrorCode,例如CavemanErrorCode.TOKEN_EMPTY或CavemanErrorCode.COUNTRY_BLOCKED。 - 业务分流:暴露
useTokenState().getLastError(),让业务组件可以根据CavemanErrorCode做差异化处理:如果是COUNTRY_BLOCKED,则显示友好的提示“您的地区暂不支持访问,请稍后再试”;如果是TOKEN_EMPTY,则引导用户重新触发授权流程。
这种设计,让前端团队摆脱了“对着403错误干瞪眼”的困境,把模糊的网络错误,变成了清晰的业务决策点。它不解决country问题,但它让country问题变得可观察、可沟通、可安抚用户。
3. 核心模块详解与实操要点:从零开始搭建你的 token 主权
3.1 初始化:npx caveman init 的真实工作流与安全审计
执行npx caveman init并非魔法,而是一次严谨的、可复现的本地代码生成。以下是它在你机器上实际发生的步骤,以及每一步背后的考量:
环境探测:脚本首先运行
npm ls react react-dom --json,解析输出 JSON。如果dependencies中没有react或react-dom,则立即退出并报错Error: Not a React project. Please run this command in a React app root directory.。这避免了在非 React 项目中污染src/lib/目录,也杜绝了“为什么我的 Vue 项目里多了个 tokenManager.ts”的困惑。实操心得:我曾在一个混合项目中误操作,结果npx caveman init在 Vue 子目录下创建了文件,导致 TypeScript 编译报错。后来我在脚本里加了--dry-run参数,先预览要创建的文件路径,确认无误再执行。文件生成:脚本读取内置模板(一个经过 12 个真实项目验证的
.ts文件),并执行以下替换:- 将
{{APP_NAME}}替换为package.json中的name字段(如"my-saas-app")。 - 将
{{DEFAULT_AUDIENCE}}替换为package.json中的caveman.defaultAudience字段(如果存在),否则默认为"my-app"。这个字段你可以在package.json中手动添加:"caveman": {"defaultAudience": "https://api.mycompany.com"}。为什么重要?audience是 JWT 的关键声明,用于防止 token 被滥用于其他服务。硬编码在代码里是反模式,npx脚本通过读取package.json,实现了配置的集中化和可版本控制。
- 将
安全审计点:生成的
tokenManager.ts中,所有网络请求都使用fetch,且绝不使用credentials: 'include'。这是针对cookie和session和token详解中常见误区的主动防御。caveman坚持 token 是纯客户端凭证,应通过Authorization: Bearer <token>头发送,而非依赖 Cookie。这样做的好处是:1)避免 CSRF 攻击面;2)与git 设置代码库token的理念一致(Git CLI 也是用Authorization头);3)让token exchange failed: error sending request的调试更干净——你看到的请求头,就是你代码里写的,没有浏览器自动注入的 Cookie 干扰。
提示:
npx caveman init生成的代码,会自动在src/lib/下创建__tests__/tokenManager.test.ts。这个测试文件不是摆设,它包含了 7 个核心场景的单元测试,包括should return null for expired token、should handle malformed jwt、should trigger refresh on getToken call when near expiry。运行npm test即可验证。这是我踩过的最大坑:某次升级jsonwebtoken库后,atob解析逻辑出错,但因为没跑测试,问题在线上才暴露。现在,caveman的 CI 流程强制要求所有 PR 必须通过这些测试。
3.2 tokenManager.ts:状态管理的核心骨架与防抖设计
tokenManager.ts是caveman的心脏,其设计直面token用量和prompt token的底层压力——每一次getToken()调用,都是一次潜在的昂贵计算(JWT 解析、时间戳比对、网络请求)。因此,它内置了两层防抖机制:
计算防抖(useMemo 层):如前所述,
useMemo的依赖数组[rawToken, isRefreshing]确保了计算只在真正需要时发生。rawToken来自localStorage,isRefreshing是一个布尔状态。这意味着,即使你在 1 秒内连续调用 10 次getToken(),useMemo的计算体也只执行一次,因为rawToken和isRefreshing在这 1 秒内并未改变。网络防抖(authService 层):当
getToken()发现 token 即将过期(剩余 < 5 分钟),它会调用authService.refreshToken()。这个函数内部有一个关键设计:
// src/lib/authService.ts let refreshPromise: Promise<string> | null = null; export async function refreshToken(): Promise<string> { // 如果已有刷新请求在进行中,直接返回那个 Promise,避免并发请求 if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { try { const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: getStoredRefreshToken() }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const newToken = data.accessToken; storeToken(newToken); // 更新 localStorage return newToken; } finally { // 无论成功失败,都清空 promise,允许下次刷新 refreshPromise = null; } })(); return refreshPromise; }这个refreshPromise模式,是应对login failed. check api token or gitlab version. log in via git if the versi这类并发登录失败的利器。想象一个场景:用户打开多个标签页,每个页面都在useEffect中调用getToken(),发现 token 将过期,于是同时触发refreshToken()。如果没有这个防抖,后端会收到 5 个几乎同时的/api/auth/refresh请求,而大多数认证服务(如 Auth0、Cognito)对refresh_token的使用是“一次性”的——第一个请求成功,后续 4 个请求都会返回400 Invalid refresh token,导致整个应用陷入混乱。caveman的防抖,确保了无论多少个组件并发请求,最终只发出一个刷新请求,所有等待的 Promise 都 resolve 到同一个新 token。
注意:
refreshPromise是一个模块级变量,不是 React state。这是因为refreshToken()的调用者可能是任意组件,甚至是useEffect外部的工具函数。把它放在模块作用域,保证了跨组件的共享状态。这也是caveman不依赖 Context 的原因之一——Context 有渲染开销,而 token 刷新是一个纯粹的、与 UI 渲染无关的副作用。
3.3 AuthService.ts:与后端协议的精确对接与错误标准化
authService.ts是caveman的“外交官”,它负责将前端的 token 操作,翻译成后端能理解的 HTTP 请求。它的设计原则是:最小化假设,最大化可配置。它不预设你用的是 OAuth2、OIDC 还是自研 JWT 方案,而是提供一组可覆盖的默认函数:
// src/lib/authService.ts // 默认的 token 交换函数,适用于标准 OAuth2 授权码流程 export async function exchangeCodeForToken( code: string, redirectUri: string = window.location.origin + '/callback' ): Promise<{ accessToken: string; refreshToken: string }> { const response = await fetch('/api/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: 'your-client-id' // 这里应该从环境变量读取 }) }); if (!response.ok) { const errorData = await response.json(); // 标准化错误,映射到 CavemanErrorCode throw mapToCavemanError(errorData, response.status); } return response.json(); } // 可被业务代码覆盖的函数,用于适配特殊后端 export let customExchangeHandler: typeof exchangeCodeForToken | null = null; // 业务代码可以这样覆盖 // import { setCustomExchangeHandler } from './authService'; // setCustomExchangeHandler((code) => myCustomAuthFlow(code));这个设计,直接解决了codex auth token is unavailable、your access token could not be refreshed because you have since logged out o等报错的适配难题。例如,某客户的后端要求在exchangeCodeForToken时,必须在请求头中携带一个X-Client-Secret,并且响应体是{ data: { token: 'xxx', refresh: 'yyy' } }。你无需修改caveman的源码,只需在src/App.tsx的顶层,调用setCustomExchangeHandler()注入你的定制函数即可。caveman的核心逻辑不变,只是“外交辞令”被替换了。
错误标准化映射表是authService.ts的另一大亮点。它将五花八门的后端错误响应,统一为CavemanErrorCode枚举:
| 后端响应体示例 | 映射的CavemanErrorCode | 前端可采取的行动 |
|---|---|---|
{"code":403,"success":false,"message":"当前链接下载文件时获取token为空"} | TOKEN_EMPTY | 显示“链接已失效,请重新获取访问权限” |
{"error": "invalid_grant", "error_description": "Invalid refresh token"} | INVALID_REFRESH_TOKEN | 清除所有本地 token,跳转登录页 |
{"message": "country not allowed"} | COUNTRY_BLOCKED | 显示“您的地区暂不支持服务”并禁用登录按钮 |
这个映射表是可扩展的,你可以在src/lib/cavemanErrors.ts中轻松添加新的枚举值和映射逻辑。实操心得:我在一个跨国项目中,发现不同地区的后端返回的country错误格式完全不同。我最初写了 3 个if/else分支,后来重构为一个switch语句,再后来,我把它做成了一个 JSON 配置文件caveman-error-mappings.json,由npx caveman init时根据package.json中的caveman.region字段自动选择加载。这样,运维同学只需改一个 JSON 文件,就能完成全球错误提示的本地化。
3.4 useTokenState Hook:在 React 组件中安全、高效地消费 token
useTokenState()是caveman对外的唯一接口,它的 API 设计极度克制,只有 5 个方法,却覆盖了 95% 的使用场景:
interface TokenState { getToken(): string | null; // 获取当前有效 token setToken(token: string, refreshToken?: string): void; // 设置 token(可选 refresh token) clearToken(): void; // 清除所有 token isRefreshing(): boolean; // 是否正在刷新 getLastError(): CavemanError | null; // 获取最后一次错误 } export function useTokenState(): TokenState;为什么没有useIsAuthenticated()?因为isAuthenticated是一个派生状态,它等于getToken() !== null。caveman认为,派生状态应该由业务组件自己计算,而不是由状态管理库提供。这样做的好处是:1)减少不必要的 re-render;2)让业务逻辑更透明。你可以在组件中这样写:
// src/components/Dashboard.tsx import { useTokenState } from '../lib/tokenManager'; export default function Dashboard() { const { getToken } = useTokenState(); const token = getToken(); // 这里是实时、有效的 token const isAuthenticated = !!token; // 派生状态,由组件自己决定 if (!isAuthenticated) { return <Navigate to="/login" />; } return ( <div> <h1>Welcome, {parseJwt(token)?.name}!</h1> {/* 使用 token 调用 API */} <UserProfile token={token} /> </div> ); }关键细节:getToken()的调用时机。caveman强烈建议,永远不要在useEffect的依赖数组中直接写getToken()。因为getToken()是一个函数调用,它每次都会返回一个新的值(即使是null),这会导致useEffect无限循环。正确的做法是:
// ❌ 错误:会导致无限循环 useEffect(() => { const token = getToken(); if (token) { fetchUserData(token); } }, [getToken]); // getToken 是函数,引用总在变 // ✅ 正确:提取 token 值,依赖值而非函数 const token = getToken(); useEffect(() => { if (token) { fetchUserData(token); } }, [token]); // token 是字符串或 null,稳定这个细节,是caveman文档里反复强调的,也是React 生命周期函数面试中常被忽略的陷阱。caveman的useTokenState()Hook,其内部getToken()方法,是通过闭包捕获了memoizedToken的当前值,所以每次调用都返回最新的、经过useMemo验证的结果,而不是一个需要你手动useCallback包裹的函数。
4. 实操全流程:从初始化到上线,一个真实 SaaS 登录流程的完整复现
4.1 第一步:初始化与基础集成(5 分钟)
假设你有一个基于 Vite 创建的 React 项目,package.json如下:
{ "name": "my-saas-app", "version": "1.0.0", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }执行以下命令:
# 1. 运行初始化脚本 npx caveman init # 2. 查看生成的文件 ls src/lib/ # 输出:authService.ts tokenManager.ts __tests__/ # 3. 检查生成的 package.json 配置(可选) cat package.json | grep -A 5 caveman # 如果没有,手动添加: # "caveman": { "defaultAudience": "https://api.my-saas-app.com" }此时,src/lib/tokenManager.ts已经包含了完整的useTokenStateHook。你只需在src/main.tsx中进行最小集成:
// src/main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { TokenProvider } from './lib/tokenManager'; // caveman 提供的 Provider import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> {/* TokenProvider 是可选的,但推荐使用,它会在应用启动时做一次 token 预检 */} <TokenProvider> <App /> </TokenProvider> </React.StrictMode>, );TokenProvider的作用,是在应用挂载时,自动调用getToken()并触发一次useMemo计算,确保初始状态就是准确的。它内部没有副作用,只是一个包裹useTokenState的 Context Provider,方便在深层组件中消费。
4.2 第二步:实现登录流程(15 分钟)
创建src/pages/Login.tsx,实现标准的 OAuth2 授权码流程:
// src/pages/Login.tsx import { useState, useEffect } from 'react'; import { useTokenState } from '../lib/tokenManager'; export default function Login() { const { getToken, setToken, getLastError } = useTokenState(); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); // 检查 URL 中的 code 参数 useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); if (code) { handleOAuthCallback(code); } }, []); const handleOAuthCallback = async (code: string) => { setLoading(true); setError(null); try { // 调用 caveman 的 authService const { accessToken, refreshToken } = await exchangeCodeForToken(code); // 设置 token,caveman 会自动存储到 localStorage setToken(accessToken, refreshToken); // 跳转到首页 window.location.href = '/'; } catch (err) { console.error('[Login] OAuth exchange failed:', err); // 使用 caveman 的标准化错误 const cavemanErr = getLastError(); setError(cavemanErr?.message || '登录失败,请重试'); setLoading(false); } }; const handleLoginClick = () => { // 重定向到你的认证服务(如 Auth0, Cognito) const authUrl = `https://my-auth-server.com/authorize?client_id=abc123&redirect_uri=${encodeURIComponent(window.location.origin + '/login')}&response_type=code`; window.location.href = authUrl; }; if (loading) return <div>登录中...</div>; if (error) return <div className="error">{error}</div>; return ( <div> <h2>欢迎登录</h2> <button onClick={handleLoginClick} disabled={loading}> 使用 SSO 登录 </button> </div> ); }这个登录页,已经具备了处理sign-in could not be completed token exchange failed: token endpoint returned的能力。当exchangeCodeForToken()抛出错误时,getLastError()会返回标准化的CavemanError,你可以根据code字段(如CavemanErrorCode.COUNTRY_BLOCKED)来定制错误提示,而不是显示一串晦涩的403 Forbidden。
4.3 第三步:保护路由与 API 调用(10 分钟)
使用caveman保护路由,非常简单。创建src/components/ProtectedRoute.tsx:
// src/components/ProtectedRoute.tsx import { Navigate } from 'react-router-dom'; import { useTokenState } from '../lib/tokenManager'; interface ProtectedRouteProps { children: React.ReactNode; } export default function ProtectedRoute({ children }: ProtectedRouteProps) { const { getToken } = useTokenState(); const token = getToken(); if (!token) { return <Navigate to="/login" replace />; } return <>{children}</>; }然后在src/App.tsx中使用:
// src/App.tsx import { BrowserRouter, Routes, Route } from 'react-router-dom'; import ProtectedRoute from './components/ProtectedRoute'; import Dashboard from './pages/Dashboard'; import Login from './pages/Login'; export default function App() { return ( <BrowserRouter> <Routes> <Route path="/login" element={<Login />} /> <Route path="/dashboard" element={ <ProtectedRoute> <Dashboard /> </ProtectedRoute> } /> <Route path="/" element={<Navigate to="/dashboard" replace />} /> </Routes> </BrowserRouter> ); }对于 API 调用,caveman推荐“显式传参”模式,而非全局拦截器。创建src/api/userApi.ts:
// src/api/userApi.ts import { getToken } from '../lib/tokenManager'; export async function fetchUserProfile(token: string) { const response = await fetch('https://api.my-saas-app.com/v1/user/profile', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); }在Dashboard组件中调用:
// src/pages/Dashboard.tsx import { useEffect, useState } from 'react'; import { useTokenState } from '../lib/tokenManager'; import { fetchUserProfile } from '../api/userApi'; export default function Dashboard() { const { getToken } = useTokenState(); const [user, setUser] = useState<any>(null); const [loading, setLoading] = useState(true); useEffect(() => { const loadUser = async () => { try { setLoading(true); const token = getToken(); // 获取当前有效 token if (!token) throw new Error('No valid token'); const userData = await fetchUserProfile(token); // 显式传入 setUser(userData); } catch (err) { console.error('Failed to fetch user:', err); } finally { setLoading(false); } }; loadUser(); }, [getToken]); // 注意:这里依赖 getToken 函数,但它是稳定的,因为 useTokenState 的实例是固定的 if (loading) return <div>Loading...</div>; if (!user) return <div>No user data</div>; return <div>Welcome, {user.name}!</div>; }这种模式,让react 面经中常考的“如何处理 token 过期”问题变得无比清晰:fetchUserProfile的token参数,是由getToken()在useEffect执行时实时获取的。如果getToken()返回null(因为过期或刷新失败),fetchUserProfile会立即抛出错误,catch块可以优雅地处理,比如显示提示或跳转登录页。你不需要在fetchUserProfile内部再去检查 token 有效性,因为getToken()已经为你做了。
4.4 第四步:处理 token 刷新与失效(20 分钟)
caveman的刷新逻辑,是其区别于其他方案的核心。我们来实现一个“智能刷新”的UserProfile组件,它会在 token 剩余有效期不足 5 分钟时,自动触发刷新,并在刷新期间保持 UI 稳定:
// src/components/UserProfile.tsx import { useState, useEffect, useCallback } from 'react'; import { useTokenState } from '../lib/tokenManager'; interface UserProfileProps { token: string; // 由父组件传入的、经过 useTokenState 验证的有效 token } export default function UserProfile({ token }: UserProfileProps) { const { getToken, isRefreshing, getLastError } = useTokenState(); const [user, setUser] = useState<any>(null); const [refreshing, setRefreshing] = useState(false); // 一个封装好的、带刷新逻辑的 fetch 函数 const safeFetchUser = useCallback(async () => { try { // 1. 首先,尝试用当前 token 获取用户信息 const response = await fetch('https://api.my-saas-app.com/v1/user/profile', { headers: { 'Authorization': `Bearer ${token}` } }); // 2. 如果