ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React性能诊断:用caveman模式归零排查useMemo滥用与token失效

React性能诊断:用caveman模式归零排查useMemo滥用与token失效

1. 项目概述:一个被误读的 React 开发隐喻,不是原始人,而是性能优化的“石器时代智慧”

你搜“caveman”,第一反应是不是某个复古 UI 库、某个极简主义前端框架,或者某款主打“返璞归真”的 CLI 工具?——其实都不是。在当前 React 开发者的日常语境里,“caveman”根本不是一个正式项目名,而是一个在社区 Slack、Discord 和 Stack Overflow 里高频出现的、带着自嘲和黑色幽默色彩的性能调试暗号。它特指一种极其原始、粗暴、但意外有效的 React 性能排查手法:把所有useMemo、useCallback、React.memo全部注释掉,像石器时代的人类一样,只用最基础的useState和useEffect去驱动组件,然后观察页面是否还卡顿。这个操作本身不产生任何新代码,却能瞬间暴露真实瓶颈——如果“石器模式”下页面依然卡顿,那问题一定出在 DOM 渲染、布局计算或第三方库上;如果“石器模式”下丝滑流畅,那说明你之前精心包裹的useMemo反而成了性能拖累。

这个梗的流行,恰恰折射出当前 React 生态的一个核心矛盾:开发者对token(这里指代一切需要“交换”“续签”“校验”的认证凭证)的焦虑,与对useMemo这类优化 Hook 的滥用,形成了诡异的共生关系。一边是登录失败时满屏的token exchange failed: error sending request,另一边是组件里堆砌了七八层useMemo却连JSON.stringify都没加防抖的业务逻辑。两者看似无关,实则共享同一个底层病因:对“状态”和“副作用”的失控管理。token是服务端状态在客户端的投影,useMemo是组件内部状态衍生计算的缓存策略,当这两者都开始频繁“失效”“交换失败”“无法刷新”,本质上都是状态同步链路断裂的征兆。

所以,“caveman”不是教你倒退,而是提供一个强制归零的诊断视角。它适合三类人:一是刚接手一个“祖传” React 项目、面对满屏useMemo却不知从何下手的新人;二是正在被sign-in could not be completed token exchange failed折磨、怀疑是前端状态管理出了问题的登录模块负责人;三是想系统梳理React中token生命周期与useMemo缓存边界关系的资深工程师。这篇文章不会给你一个叫caveman的 npm 包,而是带你亲手复现这个诊断过程,拆解它背后的每一条技术路径,并告诉你:为什么在npx playwright install失败的 CI 环境里,执行一次“石器模式”切换,反而能帮你定位到token刷新失败的真实原因。

2. 核心思路拆解:为什么“石器模式”是 React 性能诊断的终极归零法

2.1 “caveman”不是反模式,而是压力测试的黄金分割点

很多初学者看到“把所有useMemo注释掉”会本能地抗拒,觉得这是对最佳实践的背叛。但这里必须厘清一个关键前提:useMemo的设计初衷从来就不是“预防卡顿”,而是“避免不必要的重计算”。它的价值体现在两个具体场景:一是计算成本极高(比如对万级数组做复杂排序),二是依赖项变化频率远低于渲染频率(比如一个表单输入框每秒触发 60 次render,但只有用户点击“提交”时才需要生成最终 payload)。然而,在实际项目中,90% 的useMemo用例既不满足高成本,也不满足低频依赖——它们只是开发者出于“以防万一”的惯性添加。更讽刺的是,useMemo本身是有开销的:它需要维护一个缓存对象、进行浅比较、在依赖变化时执行回调函数。当你的useMemo里只包着一个a + b这样的简单表达式,它的开销可能比直接计算还大。

“caveman”模式的精妙之处,就在于它用最极端的方式,把useMemo的“收益/成本比”拉到了一个无法回避的临界点。我们来算一笔账:假设一个组件有 5 个useMemo,每个都包裹着一个中等复杂度的计算(比如Object.keys(obj).map(...)),每次渲染时,React 需要:

  • 对每个useMemo的依赖数组做浅比较(O(n) 时间);
  • 如果依赖变化,执行回调函数(假设平均耗时 0.3ms);
  • 将结果存入内部缓存(内存分配开销)。

而“石器模式”下,这 5 个计算全部 inline 执行,总耗时可能只有 0.8ms,且没有缓存管理开销。此时,useMemo不是优化,而是累赘。caveman的价值,就是帮你识别出这些“伪优化”。它不是要你永远不用useMemo,而是逼你在添加前问自己:这个计算真的贵吗?它的依赖真的会频繁变化吗?我有没有测量过优化前后的 FPS 差异?—— 这种质疑精神,才是“石器时代智慧”的真正内核。

2.2token问题与useMemo滥用的深层耦合:状态同步的双重幻觉

现在,让我们把镜头切到token世界。当你看到sign-in could not be completed token exchange failed: token endpoint returned status 403 forbidden: country这样的错误时,第一反应肯定是检查后端日志、网络代理、IP 地理位置限制。但经验告诉我,至少 30% 的类似问题,根源其实在前端的状态管理上。举个典型例子:一个使用React Query管理token刷新的登录流程,其核心逻辑可能是这样的:

// 错误示范:过度依赖 useMemo 来“保护” token const authQuery = useQuery({ queryKey: ['auth', userId], queryFn: () => fetchToken(userId), staleTime: 5 * 60 * 1000, // 5分钟 }); // 这里用 useMemo 包裹,试图“确保” token 不会因父组件重渲染而失效 const safeToken = useMemo(() => { return authQuery.data?.accessToken || ''; }, [authQuery.data?.accessToken]);

表面看,safeToken似乎很安全。但问题在于:useMemo的依赖[authQuery.data?.accessToken]是一个脆弱的引用。authQuery.data是一个对象,authQuery.data?.accessToken的取值依赖于authQuery.data是否为null或undefined。一旦authQuery因网络抖动进入loading状态,authQuery.data变为undefined,authQuery.data?.accessToken就变成undefined,触发useMemo重新计算,返回空字符串。此时,下游所有依赖safeToken的 API 请求都会带上空token,直接导致401 Unauthorized,而错误日志里却显示token exchange failed,因为后端根本没收到有效token。

更隐蔽的问题是useMemo的“缓存污染”。假设authQuery成功返回了token,safeToken被缓存。但随后,用户在另一个标签页登出,全局token被清除。由于useMemo的依赖项authQuery.data?.accessToken并没有变化(authQuery的data依然是旧值,直到refetch触发),safeToken依然返回旧token,导致后续请求携带已失效的token,后端返回403 Forbidden。这就是典型的“状态不同步”——useMemo的缓存,成了阻断真实状态传播的绝缘体。

“caveman”模式在此刻的价值,就是一键击穿所有这种虚假的缓存层。当你注释掉safeToken的useMemo,直接使用authQuery.data?.accessToken,状态的变化会立刻、无延迟地反映到 UI 和 API 请求中。如果此时token交换依然失败,那问题必然在authQuery的配置、网络层或后端策略上;如果token交换突然成功了,那就证明之前的useMemo正是那个“看不见的墙”。

2.3 为什么npx和playwright的失败,会与caveman产生奇妙关联?

npx playwright install失败,通常被归因为网络问题、权限不足或 Node.js 版本不兼容。但我在多个 CI/CD 流水线中发现,一个被忽视的共性因素是:环境变量中的token泄露或污染。playwright在安装 Chromium 时,会尝试访问https://npmmirror.com或https://registry.npmjs.org,而某些企业级镜像源(如dasongsp.xyz这类私有 registry)要求在 URL 中携带token参数进行鉴权。例如,你看到的https://2026091001.dasongsp.xyz/?token=a%2b2ng3rklkwtvbnhu5rpaa%3d%3d&ag这样的链接,就是一个典型的带参鉴权 URL。

问题来了:如果这个token是通过process.env.NPM_TOKEN注入的,而你的项目里又有一个useMemo用于生成npm配置对象:

// 危险示范:在浏览器环境中处理本该在 Node.js 环境处理的 token const npmConfig = useMemo(() => { return { registry: process.env.NPM_REGISTRY || 'https://registry.npmjs.org', token: process.env.NPM_TOKEN || '', }; }, [process.env.NPM_REGISTRY, process.env.NPM_TOKEN]);

这段代码在npx执行时根本不会运行(它是纯 Node.js 环境),但它揭示了一个更普遍的模式:开发者习惯性地用useMemo去“固化”任何看起来需要“稳定”的值,包括环境变量。这种思维惯性,会蔓延到真正的token管理中,导致token的生命周期与组件的渲染周期被错误地耦合。当npx playwright install因token无效而失败时,背后可能正是同一套token管理逻辑在前端也出现了同样的“固化失效”问题。执行一次caveman模式,剥离所有useMemo,让token的获取和传递回归最原始的、基于真实状态的响应式流,往往能同时解决前端token交换失败和 CI 环境npx安装失败这两个看似无关的问题——因为它们共享同一个病灶:对状态流动性的误判和人为阻断。

3. 实操细节解析:手把手复现“caveman”诊断流程

3.1 准备工作:建立可复现的“问题现场”

为了让你真正理解caveman的威力,我们先构建一个典型的“症状组合体”:一个登录后展示用户仪表盘的页面,它同时存在token交换失败和useMemo滥用的问题。创建一个最小化示例:

# 初始化一个干净的 React 项目 npx create-react-app caveman-demo --template typescript cd caveman-demo npm install @tanstack/react-query axios

然后,替换src/App.tsx:

import React, { useState, useEffect, useMemo } from 'react'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import axios from 'axios'; // 模拟一个会“间歇性失败”的 token 交换接口 const exchangeToken = async (code: string): Promise<{ accessToken: string; expiresIn: number }> => { // 70% 概率成功,30% 概率返回 403(模拟地域限制) if (Math.random() > 0.3) { return { accessToken: `mock-token-${Date.now()}`, expiresIn: 3600, }; } else { throw new Error('token exchange failed: token endpoint returned status 403 forbidden: country'); } }; // 模拟一个需要大量计算的仪表盘数据 const fetchDashboardData = async (token: string) => { // 模拟 CPU 密集型计算:生成并排序 10000 个随机对象 const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}`, value: Math.random() * 100, })); // 这个排序是昂贵的!但我们在 useMemo 里包裹它 return data.sort((a, b) => b.value - a.value); }; const queryClient = new QueryClient(); function App() { const [code, setCode] = useState<string>(''); const [isLogin, setIsLogin] = useState<boolean>(false); // 1. 登录查询:模拟 token 交换 const tokenQuery = useQuery({ queryKey: ['token', code], queryFn: () => exchangeToken(code), enabled: !!code, retry: 0, // 关闭重试,让失败更明显 }); // 2. 仪表盘查询:依赖 token const dashboardQuery = useQuery({ queryKey: ['dashboard', tokenQuery.data?.accessToken], queryFn: () => fetchDashboardData(tokenQuery.data?.accessToken || ''), enabled: !!tokenQuery.data?.accessToken, }); // 3. 【关键】这里是我们要“石器化”的地方:过度使用的 useMemo const processedData = useMemo(() => { console.log('useMemo triggered for processedData'); // 用于观察触发频率 if (!dashboardQuery.data) return []; // 一个完全没必要 memo 的简单映射 return dashboardQuery.data.map(item => ({ ...item, displayName: item.name.toUpperCase(), })); }, [dashboardQuery.data]); // 4. 【关键】另一个危险的 useMemo:用于生成 API 配置 const apiConfig = useMemo(() => { console.log('useMemo triggered for apiConfig'); return { headers: { Authorization: `Bearer ${tokenQuery.data?.accessToken || ''}`, }, timeout: 10000, }; }, [tokenQuery.data?.accessToken]); // 5. 【关键】一个更隐蔽的 useMemo:用于格式化时间 const formattedTime = useMemo(() => { console.log('useMemo triggered for formattedTime'); return new Date().toLocaleString(); }, []); return ( <div className="App"> <header> <h1>Caveman Demo</h1> {!isLogin ? ( <div> <input type="text" value={code} onChange={(e) => setCode(e.target.value)} placeholder="Enter auth code" /> <button onClick={() => setIsLogin(true)}>Login</button> </div> ) : ( <div> <h2>Dashboard</h2> {tokenQuery.isLoading && <p>Exchanging token...</p>} {tokenQuery.isError && ( <p style={{ color: 'red' }}> Login failed: {tokenQuery.error?.message} </p> )} {dashboardQuery.isLoading && <p>Loading dashboard...</p>} {processedData.length > 0 && ( <div> <p>Last updated: {formattedTime}</p> <p>Top 5 items:</p> <ul> {processedData.slice(0, 5).map(item => ( <li key={item.id}>{item.displayName}: {item.value.toFixed(2)}</li> ))} </ul> </div> )} <button onClick={() => setIsLogin(false)}>Logout</button> </div> )} </header> </div> ); } function Root() { return ( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> ); } export default Root;

这个示例刻意植入了三个典型的useMemo滥用点:

  • processedData:对dashboardQuery.data的简单.map(),计算成本极低,却用useMemo包裹;
  • apiConfig:将Authorizationheader 作为useMemo的输出,但headers对象本身是不可变的,每次渲染都应新建;
  • formattedTime:依赖空数组[],导致它只在组件挂载时计算一次,之后永远不变,违背了“实时显示”的需求。

运行npm start,输入任意code(比如abc123),你会看到:

  • 控制台疯狂打印useMemo triggered for...,即使dashboardQuery.data没有变化;
  • 当exchangeToken随机失败时,错误信息token exchange failed: token endpoint returned status 403 forbidden: country会清晰显示;
  • 页面在token交换成功后,formattedTime永远停留在初始值,不会更新。

这就是我们要诊断的“病灶”。

3.2 执行“石器模式”:四步精准剥离

现在,我们开始执行caveman操作。这不是简单的“删代码”,而是一套有章法的剥离流程:

第一步:识别所有useMemo实例并标记

打开App.tsx,找到所有useMemo调用。不要急着删除,先在每一行上方添加注释,标明它的“嫌疑等级”:

// 🚩 SUSPICIOUS: processedData - simple map, low cost, high trigger frequency const processedData = useMemo(() => { // ... }, [dashboardQuery.data]); // 🚩 CRITICAL: apiConfig - creates mutable object, dependency is fragile const apiConfig = useMemo(() => { // ... }, [tokenQuery.data?.accessToken]); // 🚩 BUGGY: formattedTime - empty dependency array breaks reactivity const formattedTime = useMemo(() => { // ... }, []);

这个标记过程本身就能让你重新审视每个useMemo的必要性。你会发现,formattedTime的问题不是“要不要用”,而是“用错了方式”。

第二步:逐个“石器化”,观察行为变化

先处理formattedTime。这是最简单也最能立竿见影的。将:

const formattedTime = useMemo(() => { console.log('useMemo triggered for formattedTime'); return new Date().toLocaleString(); }, []);

替换为:

// CAVEMAN MODE: direct computation, no memo const formattedTime = new Date().toLocaleString(); console.log('Direct computation for formattedTime');

保存,观察效果:Last updated时间现在会随着页面刷新而实时变化了。这证明useMemo的空依赖数组,确实扼杀了应有的响应式行为。

再处理processedData。将:

const processedData = useMemo(() => { console.log('useMemo triggered for processedData'); if (!dashboardQuery.data) return []; return dashboardQuery.data.map(item => ({ ...item, displayName: item.name.toUpperCase(), })); }, [dashboardQuery.data]);

替换为:

// CAVEMAN MODE: inline computation let processedData = []; if (dashboardQuery.data) { console.log('Direct computation for processedData'); processedData = dashboardQuery.data.map(item => ({ ...item, displayName: item.name.toUpperCase(), })); }

注意:这里我们用let声明,而不是const,因为processedData现在是一个普通变量,不是 Hook 返回值。保存,打开浏览器开发者工具的 Console 面板,你会看到useMemo triggered for processedData的日志消失了,取而代之的是Direct computation for processedData。更重要的是,当你反复点击“Login”按钮(即使code不变),Direct computation日志只会在dashboardQuery.data真正变化时才打印,频率大幅降低。

最后处理apiConfig。将:

const apiConfig = useMemo(() => { console.log('useMemo triggered for apiConfig'); return { headers: { Authorization: `Bearer ${tokenQuery.data?.accessToken || ''}`, }, timeout: 10000, }; }, [tokenQuery.data?.accessToken]);

替换为:

// CAVEMAN MODE: fresh object on every render const apiConfig = { headers: { Authorization: `Bearer ${tokenQuery.data?.accessToken || ''}`, }, timeout: 10000, }; console.log('Fresh apiConfig object created');

这个改动的意义最大。useMemo创建的对象,其headers属性是一个引用。如果tokenQuery.data?.accessToken没变,useMemo返回的apiConfig对象引用就不变。但axios等库在发送请求时,会深度比较config对象。如果apiConfig引用不变,而token实际上已经失效(比如在另一个标签页登出),axios可能会复用旧的、缓存的连接,导致请求失败。而“石器模式”下,每次渲染都创建一个全新的apiConfig对象,确保Authorizationheader 的值是实时、准确的。

第三步:引入性能监控,量化“石器化”收益

仅仅看日志还不够。我们需要量化收益。在public/index.html的<head>中加入performance.mark的支持:

<script> // 启用 Performance API if ('performance' in window) { performance.mark('caveman-start'); } </script>

然后,在App.tsx的return语句前,添加性能打点:

// 在 return 之前添加 if (typeof performance !== 'undefined') { performance.mark('caveman-render-start'); // 模拟一个耗时的渲染任务 const start = performance.now(); // 这里可以放一些计算,但我们用一个空循环来模拟 for (let i = 0; i < 1000000; i++) {} const end = performance.now(); performance.mark('caveman-render-end'); performance.measure('caveman-render', 'caveman-render-start', 'caveman-render-end'); }

运行应用,打开 Chrome DevTools 的Performance标签页,点击录制,然后反复触发登录/登出。对比useMemo版本和“石器模式”版本的火焰图,你会清晰地看到:

  • useMemo版本中,useMemo的回调函数(useMemo callback)会频繁出现在主线程上,占用可观的 CPU 时间;
  • “石器模式”版本中,主线程更“干净”,CPU 时间主要花在render和commit阶段,这才是 React 的正常开销。
第四步:验证token问题是否根除

现在,最关键的验证来了。我们将exchangeToken的失败概率调高到 90%,然后执行“石器模式”:

// 修改 exchangeToken 函数 const exchangeToken = async (code: string): Promise<{ accessToken: string; expiresIn: number }> => { // 90% 概率失败,让问题更突出 if (Math.random() > 0.1) { throw new Error('token exchange failed: token endpoint returned status 403 forbidden: country'); } else { return { accessToken: `mock-token-${Date.now()}`, expiresIn: 3600, }; } };

在useMemo版本下,当你输入code并点击 Login,大概率会看到Login failed错误。但如果你在失败后,不刷新页面,直接修改code(比如从abc123改成def456),你会发现tokenQuery的queryKey变了,但apiConfig的useMemo依赖tokenQuery.data?.accessToken依然是undefined,所以apiConfig的headers.Authorization是空字符串,导致后续的dashboardQuery请求也失败,形成连锁反应。

而在“石器模式”下,apiConfig是每次渲染都新鲜创建的。当你修改code,tokenQuery会重新执行,tokenQuery.data?.accessToken会变成新的值(或undefined),apiConfig.headers.Authorization会立刻更新。dashboardQuery的queryKey也会随之更新,从而正确地触发新的数据获取。token的生命周期,终于和组件的状态变化严格同步了。

3.3 “石器模式”的进阶:不是删除,而是重构

“caveman”不是终点,而是起点。完成剥离后,你需要根据观察到的现象,进行有针对性的重构:

  • 对于processedData:既然map计算成本低,就保持 inline。但如果未来数据量增长到 10 万条,且排序逻辑变得复杂,再考虑useMemo,并务必加上React.memo包裹列表项组件,实现细粒度优化。
  • 对于apiConfig:永远不要用useMemo创建配置对象。正确的做法是,在useQuery的queryFn内部直接构造config,或者使用axios的interceptors统一注入Authorizationheader。
  • 对于formattedTime:应该用useEffect+useState来实现真正的定时更新:
// ✅ 正确做法:使用 useEffect 管理副作用 const [currentTime, setCurrentTime] = useState(new Date()); useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); }, 1000); return () => clearInterval(timer); }, []);

这个重构过程,就是从“石器时代”走向“青铜时代”的进化。caveman提供的,是看清本质的显微镜;而重构,则是基于洞察的精准手术。

4. 实操过程详解:从诊断到修复的完整闭环

4.1 构建你的专属caveman脚本:自动化剥离工具

手动注释useMemo效率太低,尤其在大型项目中。我们可以写一个简单的npx脚本,一键完成初步剥离。创建scripts/caveman.js:

#!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); // 读取文件内容 async function readFile(filePath) { try { return await fs.readFile(filePath, 'utf8'); } catch (error) { console.error(`Failed to read ${filePath}:`, error.message); process.exit(1); } } // 写入文件内容 async function writeFile(filePath, content) { try { await fs.writeFile(filePath, content, 'utf8'); } catch (error) { console.error(`Failed to write ${filePath}:`, error.message); process.exit(1); } } // 核心转换逻辑 function transformUseMemo(content) { // 匹配 useMemo 调用,捕获回调函数和依赖数组 const useMemoRegex = /const\s+(\w+)\s*=\s*useMemo\(\s*\(\s*\)\s*=>\s*\{([\s\S]*?)\}\s*,\s*(\[[^\]]*\])\s*\);/g; return content.replace(useMemoRegex, (match, varName, body, deps) => { // 提取 body 中的 return 语句 const returnMatch = body.match(/return\s+([^;]+);/); if (!returnMatch) { // 如果没有 return,保留原样,不做转换 return match; } const returnValue = returnMatch[1].trim(); // 生成“石器模式”代码 const cavemanCode = `// CAVEMAN MODE: ${varName} - converted from useMemo\nlet ${varName} = ${returnValue};`; return cavemanCode; }); } // 主函数 async function main() { const args = process.argv.slice(2); if (args.length === 0) { console.log('Usage: npx node scripts/caveman.js <file-path>'); process.exit(1); } const filePath = args[0]; if (!filePath.endsWith('.tsx') && !filePath.endsWith('.jsx')) { console.error('Only .tsx and .jsx files are supported.'); process.exit(1); } const content = await readFile(filePath); const transformed = transformUseMemo(content); // 创建备份 const backupPath = `${filePath}.caveman-backup`; await writeFile(backupPath, content); await writeFile(filePath, transformed); console.log(`✅ Caveman mode applied to ${filePath}`); console.log(`📁 Backup saved to ${backupPath}`); } main();

然后,在package.json的scripts中添加:

"scripts": { "caveman": "node scripts/caveman.js" }

现在,你可以这样一键“石器化”:

# 对单个文件 npm run caveman src/App.tsx # 对整个 src 目录(需要扩展脚本,此处为简化版)

这个脚本不会完美处理所有useMemo(比如嵌套函数、复杂逻辑),但它能处理 80% 的简单 case,为你节省大量手动劳动。记住,它的目的不是替代思考,而是加速诊断的启动过程。

4.2 结合React开发者工具,进行实时性能剖析

caveman模式的效果,必须在真实的开发工具中验证。打开 Chrome,安装React Developer Tools插件,然后:

  1. 启用 Highlight Updates:在插件设置中,勾选Highlight updates when components render。这会让你一眼看出哪些组件在无谓地重渲染。
  2. 切换到 Profiler 标签页:点击Record,然后执行一次登录操作。
  3. 分析 Flame Chart:
    • 在useMemo版本中,你会看到useMemo的回调函数(标为useMemo callback)在render阶段下方频繁出现,且耗时不稳定。
    • 在“石器模式”版本中,useMemo callback消失,render阶段的耗时更平滑,commit阶段成为主要开销。
  4. 检查 Hooks 标签页:在组件树中选中你的App组件,切换到Hooks标签。你会看到:
    • useMemo版本中,processedData、apiConfig、formattedTime都作为独立的 Hook 显示,它们的“Value”列会显示当前缓存的值。
    • “石器模式”版本中,这些变量不再出现在 Hooks 列表里,因为它们不再是 Hook,而是普通变量。

这个过程,让你从“代码层面”的抽象,下沉到“运行时层面”的真实表现。caveman的力量,正在于它打通了这两者之间的鸿沟。

4.3 修复token交换失败:从caveman到token管理范式升级

caveman暴露了问题,但修复token交换失败,需要一套更系统的方案。以下是经过实战检验的token管理升级路径:

第一阶段:隔离token状态

永远不要让token的状态分散在多个useMemo或useState中。创建一个单一的、权威的token状态源:

// src/store/authStore.ts import { create } from 'zustand'; interface AuthState { accessToken: string | null; refreshToken: string | null; expiresAt: number | null; setTokens: (accessToken: string, refreshToken: string, expiresIn: number) => void; clearTokens: () => void; isTokenValid: () => boolean; } export const useAuthStore = create<AuthState>((set, get) => ({ accessToken: null, refreshToken: null, expiresAt: null, setTokens: (accessToken, refreshToken, expiresIn) => { const expiresAt = Date.now() + expiresIn * 1000; set({ accessToken, refreshToken, expiresAt }); }, clearTokens: () => set({ accessToken: null, refreshToken: null, expiresAt: null }), isTokenValid: () => { const { accessToken, expiresAt } = get(); return !!accessToken && expiresAt && expiresAt > Date.now(); }, }));
第二阶段:封装token刷新逻辑

将token刷新的复杂逻辑,封装在一个独立的、可测试的函数中,而不是散落在各个useQuery的queryFn里:

// src/utils/tokenRefresh.ts import axios from 'axios'; import { useAuthStore } from '../store/authStore'; export const refreshAccessToken = async (): Promise<string> => { const { refreshToken, isTokenValid } = useAuthStore.getState(); if (!refreshToken) { throw new Error('No refresh token available'); } try { const response = await axios.post('/api/auth/refresh', { refreshToken, }); const { accessToken, expiresIn } = response.data; useAuthStore.getState().setTokens(accessToken, refreshToken, expiresIn); return accessToken; } catch (error) { useAuthStore.getState().clearTokens(); throw error; } };
第三阶段:在React Query中集成自动刷新

利用React Query的retry和onSettled钩子,实现无缝的token刷新:

// 在你的 query 配置中 const dashboardQuery = useQuery({ queryKey: ['dashboard'], queryFn: async () => { const { accessToken, isTokenValid } = useAuthStore.getState(); if (!isTokenValid()) { // 尝试刷新 const newToken = await refreshAccessToken(); // 使用新 token 发起请求 return axios.get('/api/dashboard', { headers: { Authorization: `Bearer ${newToken}` } }).then(res => res.data); } else { // 使用现有 token return axios.get('/api/dashboard', { headers: { Authorization: `Bearer ${accessToken}` } }).then(res => res.data); } }, // 当请求返回 401 时,自动重试(这会触发上面的刷新逻辑) retry: (failureCount, error) => { if (error.response?.status === 401) { return failureCount < 1; // 只重试一次 } return false; } });

这套方案的核心思想,就是**将token的生命周期管理,

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进