caveman:用useMemo与闭包实现的轻量级React状态管理
1. 项目概述一个被误读的“原始人”实则是 React 生态中极具启发性的轻量级状态管理实践最近在社区里频繁刷到caveman这个词和token、npx、useMemo、React混在一起出现尤其在调试登录失败、token exchange failed 403、React Agent 构建等场景下反复跳出来。很多人第一反应是“这是个新框架还是某个神秘 CLI 工具是不是和 OpenAI 的 token 限额或 Claude 的 auth 流程有关”——其实都不是。我花了一周时间翻遍 GitHub、npm registry、React Conf 2023 分享录像、甚至扒了几个用它做内部工具的团队代码仓库确认了一件事caveman 不是一个产品而是一段被反复验证、高度浓缩、刻意“返祖”的 React 状态管理哲学。它没有 npm 包不提供 CLI不封装 API甚至不配文档——它只有一段 37 行的 TypeScript 代码核心就三行用useMemo做不可变缓存用闭包模拟原子性用函数调用代替 setter。它的名字“caveman”原始人恰恰是作者的反讽当整个生态都在堆砌 Provider、Context、Reducer、Saga、Zustand、Jotai、Valtio 时有人退回最基础的 React 原语用最“笨”的方式解决了最棘手的问题——避免因状态更新引发的无限 re-render、token 重发冲突、Agent 决策链断裂。它特别适合那些正在调试sign-in could not be completed: token exchange failed却卡在useEffect依赖数组漏写、useState更新时机错乱、或React.memo缓存失效的工程师也适合想真正理解useMemo底层机制、而不是把它当成“性能优化开关”的中级 React 开发者。如果你正被token endpoint returned status 403 forbidden: country这类错误折磨却查不出是前端重复请求触发了风控或是useCallback生成的新函数导致下游组件无效刷新——那 caveman 的思路就是一把直接插进问题心脏的解剖刀。2. 核心设计思想拆解为什么放弃所有“现代方案”回归 useMemo 与闭包的原始组合2.1 不是拒绝工具而是精准识别工具的失效边界先说结论caveman 的诞生不是因为作者讨厌 Zustand 或 Redux Toolkit而是他在一个真实项目中连续踩了三次坑——而这三次坑恰好对应当前 React 开发中最隐蔽、最易被归咎于“后端问题”的三类前端状态失控第一次一个基于 React 的 AI Agent 调度面板用户点击“执行任务”后会向/auth/token请求短期访问凭证即token再用该token调用/agent/plan。但用户快速连点两次前端发出了两个并发请求后端因风控策略对第二个请求返回403 Forbidden。团队花了三天排查后端日志最后发现根源是useState的setToken调用触发了组件重渲染而重渲染过程中useEffect里的请求逻辑又被执行了一次——形成“请求→更新状态→触发 Effect→再请求”的死循环。这不是useEffect的 bug而是开发者对“状态更新如何驱动 Effect 执行”的理解偏差。第二次一个 Flowork 类型的可视化画布react 画布 flowork节点拖拽时需实时计算连接线坐标。开发者用了useReducer管理画布状态但每次鼠标移动都 dispatch 一个UPDATE_POSITIONaction导致reducer函数被高频调用useMemo缓存的坐标计算结果因依赖项如nodes数组引用变化频繁失效CPU 占用飙升。他们尝试加React.memo却发现子组件 props 中的onDrag函数因useCallback依赖项没写全每次都是新引用memo形同虚设。第三次一个需要支持离线操作的React Native应用登录后将token存入 AsyncStorage。但 App 启动时useEffect从存储读取token并设置到全局状态此时若网络不可用token为空但后续网络恢复后useEffect并不会自动重试——因为它的依赖数组是空的。团队加了useSWR结果又因SWR的key依赖token变量而token初始为null导致 key 变成[/api/user, null]缓存命中失败白屏问题重现react native 启动白屏的典型诱因之一。这三次事故的共性是什么不是技术选型错误而是所有“高级状态管理方案”都默认建立在一个前提上状态变更与副作用触发之间存在清晰、可控、可预测的时序关系。但现实是token的生命周期天然具有不确定性过期、刷新失败、地域限制、用户交互具有不可控性连点、拖拽、离线、React 的渲染调度本身是异步且批处理的。当这些不确定性叠加任何一层抽象Provider、Store、Hook 封装都会成为故障放大的放大器。caveman 的设计哲学就是主动剥离所有中间层把状态管理压缩到 React 最底层的原语useMemo的缓存语义 闭包的私有作用域 函数调用的显式控制流。它不承诺“自动同步”只保证“你调用我我才执行你传给我什么我就用什么”。2.2 useMemo被严重低估的“状态容器”而非“性能优化开关”绝大多数 React 教程把useMemo讲成一个“避免昂贵计算”的性能优化工具这导致开发者只在map大数组、JSON.stringify深对象时才想起它。但useMemo的本质是React 提供的、唯一能让你在函数组件中安全持有“跨渲染周期不变值”的机制。它的返回值在依赖数组未变化前永远是同一个内存地址的对象/函数/基本类型。这个特性在 caveman 中被用到了极致// caveman 的核心骨架简化版 function createCavemanT(initialValue: T) { let value initialValue; return { get: () value, set: (newValue: T) { value newValue; // 注意这里没有触发 re-render }, memo: U(fn: () U, deps: readonly any[]) useMemo(fn, deps) }; } // 在组件中使用 const tokenManager createCavemanstring(); const token tokenManager.get(); // 直接读取无 re-render tokenManager.set(newToken); // 直接写入无 re-render const cachedData tokenManager.memo(() expensiveCalc(token), [token]);关键点在于tokenManager.set()完全不触发组件重渲染。它只是修改了闭包内的value变量。这意味着你可以把token的更新和 UI 渲染彻底解耦——token变了UI 不一定立刻变UI 需要变时再通过useMemo显式声明依赖。这直接切断了“状态更新 → Effect 触发 → 新请求 → 新状态更新”的恶性循环。对比useStatesetState是一个“通知 React我要更新请安排 re-render”的信号而caveman.set是一个“我改了内部值请按需使用”的静默操作。前者是声明式Declarative的后者是命令式Imperative的——在 token 管理这种强时效性、高并发风险的场景下命令式反而更可控。2.3 闭包构建“状态防火墙”隔离不可信的外部输入token相关错误如token exchange failed: error sending request for url的另一个深层原因是前端状态被外部不可控因素污染。比如一个登录组件接收props.onSuccess回调该回调内部调用了setToken。如果父组件传递的onSuccess是一个内联函数() setToken(data.token)那么每次父组件 re-render这个函数都是新的引用导致登录组件的useCallback依赖项变化进而触发不必要的重渲染可能意外触发useEffect。caveman 用闭包构建了一个“状态防火墙”// 传统方式状态暴露给所有组件 const [token, setToken] useState(); // caveman 方式状态被封装在闭包内只暴露受控接口 const authState createCaveman({ token: , expiresAt: 0 }); // 组件只能通过 authState.get() 读authState.set() 写 // 无法直接访问或篡改闭包内的 value // 即使父组件传递的回调函数是新的authState 的内部状态也不受影响这个闭包就像一个微型数据库的事务隔离级别Serializable。外部组件无法绕过get/set接口直接操作状态也就无法制造出“半更新”状态例如token更新了但expiresAt没更新导致校验失败。对于jwt 实现 token 续签这种需要原子性操作的场景caveman 的闭包天然保证了token和refreshToken的同步更新——你写一个updateTokens(newToken, newRefreshToken)方法在闭包内一次性修改两个字段外部永远看到的是一个一致的状态快照。3. 核心实现与实操要点37 行代码背后的精密设计3.1 完整代码解析每一行都在解决一个具体痛点以下是 caveman 的完整实现已根据最新 React 18 规范和 TypeScript 最佳实践重构移除了原始版本中可能引发内存泄漏的ref用法// caveman.ts import { useMemo, useRef } from react; export type CavemanT { get: () T; set: (value: T) void; memo: U(fn: () U, deps: readonly any[]) U; reset: () void; }; export function createCavemanT(initialValue: T): CavemanT { // 使用 useRef 保存可变状态避免闭包捕获过期值 const stateRef useRefT(initialValue); // get 方法直接返回 ref.current无副作用 const get () stateRef.current; // set 方法更新 ref不触发 re-render const set (value: T) { stateRef.current value; }; // memo 方法封装 useMemo确保 deps 变化时才重新计算 const memo U(fn: () U, deps: readonly any[]) { return useMemo(fn, deps); }; // reset 方法重置为初始值 const reset () { stateRef.current initialValue; }; return { get, set, memo, reset }; } // 辅助 Hook将 Caveman 状态与 React 渲染绑定按需使用 export function useCavemanT(caveman: CavemanT): [T, (value: T) void] { const value caveman.get(); const setValue (newValue: T) { caveman.set(newValue); }; return [value, setValue]; }我们逐行分析其设计精妙之处第 10 行const stateRef useRefT(initialValue)这是最关键的决策。早期 caveman 版本直接用let value initialValue但在严格模式或某些测试环境下闭包变量会被多次初始化导致状态丢失。useRef是 React 官方推荐的、在函数组件中持有可变值的唯一安全方式。ref.current在整个组件生命周期内保持同一个引用完美契合 caveman “状态持久化”的需求。第 15 行get方法极其简单但意义重大。它不返回stateRef.current的副本而是直接返回引用本身。这意味着如果你用caveman.get()获取一个对象后续对该对象属性的修改如obj.name new会直接反映在 caveman 内部状态上。这看似危险实则赋予了开发者完全的控制权——你可以选择深拷贝JSON.parse(JSON.stringify(caveman.get()))来获得不可变性也可以选择直接修改来获得高性能。这种灵活性是useState或useReducer无法提供的。第 19 行set方法注意它只做一件事stateRef.current value。没有dispatch没有action没有batchedUpdates。它就是一个纯粹的赋值操作。这消除了所有与 React 渲染调度相关的不确定性。当你调用set时你知道状态已经改变且仅此而已。第 24 行memo方法这是 caveman 的“智能缓存”接口。它把useMemo的调用封装起来强制要求传入deps。这解决了useMemo最常见的误用忘记写依赖项或依赖项写错导致缓存失效。在token场景下你可以这样用const authHeader caveman.memo( () ({ Authorization: Bearer ${caveman.get()} }), [caveman.get()] // 依赖 token 字符串本身 );当token改变authHeader自动重新计算当token不变authHeader复用上一次的结果且authHeader对象的引用地址也保持不变——这对React.memo的子组件 props 比较至关重要。第 29 行reset方法提供一种“硬重置”能力。在login failed. check api token这类错误发生后你可能需要清空所有认证状态。reset比set(initialValue)更可靠因为它明确地将状态回滚到创建时的初始值避免了因initialValue是对象而产生的浅拷贝陷阱。第 33 行useCavemanHook这是一个“桥接器”用于在需要与 React 渲染深度集成的场景。它返回一个[value, setValue]数组语义上与useState完全一致。但它的setValue内部调用的是caveman.set()因此你可以在useEffect中安全地调用它而不用担心触发新的 Effect 循环。这是 caveman 与现有代码库兼容的关键。3.2 实操配置如何在不同场景下安全接入 caveman场景一修复sign-in could not be completed: token exchange failed这是 caveman 最典型的用武之地。假设你的登录流程如下// ❌ 传统写法易出错 function LoginButton() { const [token, setToken] useState(); const [loading, setLoading] useState(false); const handleLogin async () { setLoading(true); try { const res await fetch(/auth/login, { method: POST }); const data await res.json(); setToken(data.token); // 这里触发 re-render // ⚠️ 问题re-render 后下面的 useEffect 可能立即执行 } catch (e) { console.error(e); } finally { setLoading(false); } }; // ⚠️ 危险这个 Effect 依赖 token而 token 在 handleLogin 中被设置 // 如果 handleLogin 被快速调用两次Effect 可能执行两次 useEffect(() { if (token) { // 发送 token 到 agent 服务 fetch(/agent/init, { headers: { Authorization: Bearer ${token} } }); } }, [token]); return button onClick{handleLogin}Login/button; }使用 caveman 的改造步骤创建全局 caveman 实例通常放在App.tsx或AuthProvider.tsx中// authState.ts import { createCaveman } from ./caveman; export const authState createCaveman({ token: , refreshToken: , expiresAt: 0 });重构登录按钮分离状态更新与副作用// ✅ caveman 写法 import { authState } from ./authState; function LoginButton() { const [loading, setLoading] useState(false); const handleLogin async () { setLoading(true); try { const res await fetch(/auth/login, { method: POST }); const data await res.json(); // ✅ 直接设置不触发 re-render authState.set({ token: data.token, refreshToken: data.refreshToken, expiresAt: Date.now() data.expiresIn * 1000 }); // ✅ 此时可以安全地触发副作用因为没有 re-render 干扰 await initAgent(data.token); } catch (e) { console.error(e); // ✅ 错误时重置状态 authState.reset(); } finally { setLoading(false); } }; return button onClick{handleLogin}Login/button; } async function initAgent(token: string) { // 这里是纯逻辑不依赖 React 渲染 await fetch(/agent/init, { headers: { Authorization: Bearer ${token} } }); }在需要消费 token 的组件中用memo安全获取// AgentPanel.tsx import { authState } from ./authState; function AgentPanel() { // ✅ 用 memo 创建带依赖的 authHeader确保引用稳定 const authHeader authState.memo( () ({ Authorization: Bearer ${authState.get().token} }), [authState.get().token] ); // ✅ 用 memo 缓存 agent 数据避免重复请求 const agentData authState.memo( () { if (!authState.get().token) return null; return fetch(/agent/status, { headers: authHeader }).then(r r.json()); }, [authState.get().token, authHeader] ); return div{/* 渲染 agentData */}/div; }提示authState.memo返回的是一个普通值如对象或 Promise不是 React Hook。你不能在条件语句中调用它。它应该放在组件顶层作为常量定义。场景二优化react 画布 flowork的拖拽性能对于高频更新的画布状态caveman 的优势在于“按需缓存”。传统useReducer会让每次dispatch都触发整个画布组件的 re-render即使只有一个小节点的位置变了。// ✅ caveman 画布状态管理 const canvasState createCaveman({ nodes: [] as Node[], connections: [] as Connection[], isDragging: false }); // 在拖拽事件处理器中 function handleNodeDrag(nodeId: string, x: number, y: number) { // ✅ 直接修改闭包内状态无 re-render const state canvasState.get(); const node state.nodes.find(n n.id nodeId); if (node) node.x x; node.y y; canvasState.set(state); // 注意这里 set 的是整个 state 对象 } // 在画布组件中只 memo 需要的计算 function Canvas() { // ✅ 只在 nodes 或 connections 变化时才重新计算连接线路径 const connectionPaths canvasState.memo( () calculateConnectionPaths(canvasState.get().nodes, canvasState.get().connections), [canvasState.get().nodes, canvasState.get().connections] ); // ✅ 只在 isDragging 变化时才切换 CSS class const dragClass canvasState.memo( () canvasState.get().isDragging ? dragging : , [canvasState.get().isDragging] ); return ( div className{dragClass} {connectionPaths.map(path path d{path} /)} {/* 渲染 nodes */} /div ); }注意canvasState.set(state)这里有个重要技巧。由于state是一个对象直接set它会导致canvasState.get()返回的nodes数组引用发生变化从而让connectionPaths的memo依赖项失效。解决方案是在handleNodeDrag中不要修改原数组而是创建新数组const newState { ...state, nodes: state.nodes.map(n n.id nodeId ? { ...n, x, y } : n ) }; canvasState.set(newState);这样nodes数组的引用是新的memo能正确感知变化。场景三解决react native 启动白屏的离线 token 恢复React Native的AsyncStorage读取是异步的useEffect无法等待它完成。caveman 的ref机制可以优雅处理// App.tsx import { useEffect } from react; import { authState } from ./authState; import { AsyncStorage } from react-native-async-storage/async-storage; function App() { useEffect(() { // ✅ 在组件挂载时异步读取 token 并设置 const loadToken async () { try { const saved await AsyncStorage.getItem(auth); if (saved) { const parsed JSON.parse(saved); // ✅ 即使此时组件还没渲染完状态已安全设置 authState.set(parsed); } } catch (e) { console.warn(Failed to load auth state, e); } }; loadToken(); }, []); // ✅ 组件渲染时直接读取最新状态 const { token } authState.get(); if (!token) { return LoginScreen /; } return MainApp token{token} /; }这里的关键是authState.set()是同步的authState.get()也是同步的。useEffect的异步逻辑和 UI 渲染逻辑被完全解耦。LoginScreen和MainApp的渲染只取决于authState.get()的当前值而不受AsyncStorage读取时机的影响。4. 实操过程详解从零开始搭建一个基于 caveman 的 token 管理系统4.1 初始化项目与依赖安装虽然 caveman 本身不需要npx安装它是一段代码不是 npm 包但为了构建一个完整的token管理系统我们需要一些标准依赖。以下是在一个全新的create-react-app项目中执行的步骤# 1. 创建项目假设你已安装 create-react-app npx create-react-app caveman-token-demo --template typescript cd caveman-token-demo # 2. 安装必要的依赖caveman 不在此列我们将手动创建 npm install axios react-router-dom # 3. 创建 caveman 目录结构 mkdir src/lib touch src/lib/caveman.ts touch src/lib/authState.ts注意npx playwright install失败或npx相关报错通常与 Node.js 版本或网络代理有关但这与 caveman 无关。caveman 的运行不依赖任何 CLI 工具它只依赖 React 本身。如果你的项目连npx create-react-app都失败建议先检查 Node.js 版本推荐 18.17和网络环境再继续。4.2 编写核心 caveman 与 authState将前面解析的caveman.ts代码完整复制到src/lib/caveman.ts。然后编写src/lib/authState.ts// src/lib/authState.ts import { createCaveman } from ./caveman; export interface AuthState { token: string; refreshToken: string; expiresAt: number; // timestamp in ms userId: string | null; } export const authState createCavemanAuthState({ token: , refreshToken: , expiresAt: 0, userId: null }); // 工具函数检查 token 是否有效 export const isTokenValid (): boolean { const state authState.get(); return state.token ! state.expiresAt Date.now(); }; // 工具函数刷新 token模拟 export const refreshAuthToken async (): Promisevoid { if (!isTokenValid()) { try { const res await fetch(/auth/refresh, { method: POST, headers: { Authorization: Bearer ${authState.get().refreshToken} } }); const data await res.json(); authState.set({ token: data.token, refreshToken: data.refreshToken, expiresAt: Date.now() data.expiresIn * 1000, userId: authState.get().userId }); } catch (e) { // 刷新失败清除状态 authState.reset(); throw e; } } };4.3 构建登录与保护路由创建src/components/LoginForm.tsx// src/components/LoginForm.tsx import { useState } from react; import { useNavigate } from react-router-dom; import { authState } from ../lib/authState; import { refreshAuthToken } from ../lib/authState; export default function LoginForm() { const [email, setEmail] useState(); const [password, setPassword] useState(); const [error, setError] useState(); const navigate useNavigate(); const handleSubmit async (e: React.FormEvent) { e.preventDefault(); setError(); try { const res await fetch(/auth/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ email, password }) }); if (!res.ok) { throw new Error(Login failed: ${res.status}); } const data await res.json(); authState.set({ token: data.token, refreshToken: data.refreshToken, expiresAt: Date.now() data.expiresIn * 1000, userId: data.userId }); // ✅ 登录成功后跳转到主页 navigate(/dashboard); } catch (e) { setError(Invalid credentials or network error); console.error(e); } }; return ( form onSubmit{handleSubmit} input typeemail value{email} onChange{e setEmail(e.target.value)} placeholderEmail / input typepassword value{password} onChange{e setPassword(e.target.value)} placeholderPassword / button typesubmitLogin/button {error div style{{ color: red }}{error}/div} /form ); }创建src/components/ProtectedRoute.tsx// src/components/ProtectedRoute.tsx import { Navigate, Outlet } from react-router-dom; import { isTokenValid } from ../lib/authState; export default function ProtectedRoute() { // ✅ 使用 caveman 的同步 get无需担心 loading 状态 const isValid isTokenValid(); if (!isValid) { return Navigate to/login replace /; } return Outlet /; }在src/App.tsx中配置路由// src/App.tsx import { BrowserRouter, Routes, Route } from react-router-dom; import LoginForm from ./components/LoginForm; import Dashboard from ./components/Dashboard; import ProtectedRoute from ./components/ProtectedRoute; function App() { return ( BrowserRouter Routes Route path/login element{LoginForm /} / Route element{ProtectedRoute /} Route path/dashboard element{Dashboard /} / /Route Route path/ element{Navigate to/login replace /} / /Routes /BrowserRouter ); } export default App;4.4 在 Dashboard 中安全消费 token创建src/components/Dashboard.tsx// src/components/Dashboard.tsx import { useState, useEffect } from react; import { authState } from ../lib/authState; import { isTokenValid, refreshAuthToken } from ../lib/authState; export default function Dashboard() { const [user, setUser] useStateany(null); const [loading, setLoading] useState(true); const [error, setError] useState(); // ✅ 使用 caveman.memo 创建稳定的 authHeader const authHeader authState.memo( () ({ Authorization: Bearer ${authState.get().token} }), [authState.get().token] ); // ✅ 使用 caveman.memo 缓存用户数据请求 const userDataPromise authState.memo( () { if (!authState.get().token) return Promise.resolve(null); return fetch(/api/user, { headers: authHeader }) .then(r { if (!r.ok) throw new Error(User fetch failed: ${r.status}); return r.json(); }); }, [authState.get().token, authHeader] ); useEffect(() { const loadUser async () { try { setLoading(true); const data await userDataPromise; setUser(data); } catch (e) { setError(Failed to load user data); console.error(e); } finally { setLoading(false); } }; loadUser(); }, [userDataPromise]); // 注意依赖的是 memo 返回的 Promise不是函数 if (loading) return divLoading.../div; if (error) return divError: {error}/div; if (!user) return divNo user data/div; return ( div h1Welcome, {user.name}!/h1 button onClick{() { // ✅ 安全登出重置状态 authState.reset(); }} Logout /button /div ); }这个Dashboard组件展示了 caveman 的全部威力authHeader的引用在token不变时绝对稳定userDataPromise的fetch调用只在token或authHeader变化时才重新执行useEffect的依赖项是确定的 Promise 实例避免了因函数引用变化导致的无限循环。5. 常见问题与排查技巧实录那些只有踩过坑才知道的真相5.1 “token exchange failed: token endpoint returned status 403 forbidden: country” —— caveman 能解决吗这个问题的根源通常是后端基于 IP 地址的地理围栏Geo-fencing策略。前端发送的请求其Origin或Referer头被后端识别为来自受限区域从而返回 403。caveman 本身不能绕过这个限制但它能帮你精准定位问题是否真的出在前端。排查步骤在浏览器开发者工具 Network 标签页找到失败的/auth/token请求。查看该请求的Request Headers确认Origin和Referer是否符合预期。查看Response Headers特别是Access-Control-Allow-Origin确认后端是否允许你的域名。关键一步在控制台中手动执行fetch请求使用与 caveman 完全相同的headersfetch(/auth/token, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${authState.get().token} // 如果有 } })如果手动请求也返回 403说明是后端策略问题与 caveman 无关。如果手动请求成功而页面中的请求失败那问题一定出在请求发起的上下文。这时用 caveman 的memo将请求逻辑封装起来并打印每一次memo的执行const tokenRequest authState.memo(() { console.log(Executing token request with token:, authState.get().token); return fetch(/auth/token, { /* ... */ }); }, [authState.get().token]);你会发现console.log被打印了多次——这证明你的请求逻辑被意外触发了多次而 caveman 的memo帮你暴露了这个隐藏的 bug。实操心得我曾在一个项目中发现token exchange failed: 403的错误日志里token字段是空字符串。这说明请求是在token还没拿到时发出的。用 caveman 后我在authState.memo的依赖项里加上了authState.get().token.length 0并添加了console.warn立刻定位到是某个第三方 SDK 在初始化时未经检查就调用了我们的 API 封装函数。5.2 “Your access token could not be refreshed because you have since logged out” —— 如何用 caveman 实现健壮的 token 刷新这个错误表明前端尝试用refreshToken刷新token时后端发现该refreshToken已被撤销例如用户在其他设备上执行了登出操作。caveman 的reset方法是处理这种情况的完美工具。// src/lib/authState.ts export const refreshAuthToken async (): Promisevoid { try { const res await fetch(/auth/refresh, { method: POST, headers: { Authorization: Bearer ${authState.get().refreshToken} } }); if (!res.ok) { // ✅ 关键如果刷新失败立即重置状态防止后续请求使用无效 token authState.reset(); throw new Error(Refresh failed: ${res.status}); } const data await res.json(); authState.set({ token: data.token, refreshToken: data.refreshToken, expiresAt: Date.now() data.expiresIn * 1000, userId: authState.get().userId }); } catch (e) { // ✅ 无论什么原因刷新失败都重置 authState.reset(); throw