ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

401错误与响应拦截器:token过期并发刷新合流

401错误与响应拦截器:token过期并发刷新合流 上周三晚上十一点测试同学在群里丢了张截图订单列表点进去整页白控制台一片红全是 401。我第一反应是后端把 token 校验逻辑改了结果翻了下请求发现前端在同一个用户会话里连着发了七个刷新请求最后把服务端的刷新接口限流触发了所有后续请求全部 401页面就卡在加载态转圈。这件事之后我把项目里所有涉及401 错误的处理逻辑重写了一遍核心就一个东西响应拦截器。如果你正在写前端请求层或者被token 过期后页面乱跳、请求重复、白屏这些问题折腾过这篇就是我把这套东西从踩坑到落地的完整记录从 401 的本质讲到拦截器的并发合流实现代码可以直接拿去改。1. 401 不是登录失败是这个回合的凭证失效了很多人把 401 当成没登录的同义词这个认知偏差是后面所有 bug 的源头。401 Unauthorized 在 HTTP 语义里表达的是服务器收到了请求但这次的身份凭证不满足要求。注意关键词是这次——它描述的是一个请求级别的状态不是一个用户级别的状态。同一个用户第一个请求 401刷新 token 后第二个请求就能 200这中间用户根本没有重新登录这个动作。把 401 处理成用户掉线了踢回登录页等于把一个可以静默修复的问题升级成了用户可感知的故障。1.1 401 和 403 的分界线在哪里这两个状态码在中文语境里经常被混用但它们指向完全不同的处理路径。401 是我不知道你是谁403 是我知道你是谁但你不能干这个。前者应该尝试补凭证后者补凭证没有任何意义——你拿一百个新 token 去请求一个不属于你的资源结果还是 403。维度401 Unauthorized403 Forbidden认证状态凭证缺失、过期、格式错误凭证有效身份已确认重试是否有意义有刷新凭证后重试无重试结果不变前端正确动作刷新 token 并重放请求提示无权限不重试不跳登录典型触发token 过期、token 被篡改普通用户访问管理员接口标准响应头通常带 WWW-Authenticate一般不带我在项目里见过最典型的一个 bug后端把无权限访问也返回了 401前端拦截器一看 401 就跳登录页导致普通用户点了个管理员菜单直接被踢下线用户以为自己账号出问题了投诉了一大堆。后来我们做的第一件事就是跟后端对齐语义认证失败返 401授权失败返 403这条约定比任何代码优化都值钱。1.2 那些根本不是 401 的401还有一种更隐蔽的情况你看到的 401 其实不是业务后端返回的而是被中间层挡下来的。比如网关层的 token 校验、CDN 的回源鉴权、反向代理的 Basic Auth这些环节可能在请求真正到达业务服务之前就返回了 401。这种情况下你的业务代码里压根没有对应的鉴权逻辑前端去刷新 token 刷一百次也没用。判断方法很简单看响应体。业务后端返回的 401 一般有 JSON body里面有 code、message 之类的字段网关或代理返回的 401 往往是一个 HTML 错误页或者干脆是空 body 加一个WWW-Authenticate: Basic realm...的响应头。我在浏览器 Network 面板里判断这类问题时第一眼看的就是 Response 选项卡里的内容形态这一步能省掉大量无意义的排查时间。另外还有一种拿不到的 401。如果接口是跨域调用且Access-Control-Allow-Origin配置和你的withCredentials设置不匹配浏览器会在拿到 401 响应后直接拦截JS 侧的error.response是undefinederror.response.status自然也就取不到值有些库会把它显示成 status 0。这时候你去拦截器里判断status 401永远不成立请求就这么静默失败了。这种情况必须从 CORS 配置入手而不是在拦截器里打补丁。1.3 为什么每个请求各自处理 401 必然失控刚开始写项目的时候我们习惯在每个接口的 catch 里判断状态码然后各自决定是跳登录还是弹提示。业务代码少的时候没感觉等到接口数量上到三四十个问题就集中爆发了有人写了跳登录有人写了弹 toast有人什么都没写。同一个 token 过期事件因为页面上同时发起了五个请求就会触发五次跳转、三次弹窗用户体验极差。更要命的是逻辑不一致导致的隐性 bug。某个接口在 401 后自己拼接了一个旧 token 重试另一个接口直接放弃了结果页面上半部分是旧数据、下半部分是空状态看起来像是部分加载失败排查起来非常费劲。401 的处理必须是全局唯一入口这是所有后续优化的前提没有这个前提谈并发控制、队列合流都是空话。2. 响应拦截器为什么是收口 401 的唯一合理位置响应拦截器处在请求生命周期的末端它能同时拿到三样东西原始请求配置、响应对象、错误对象。这三样东西恰好构成了判断是否需要重试、重试时怎么改配置、重试失败怎么收场的完整信息闭环。放在拦截器里处理业务代码里就再也不需要关心 token 状态调用方只管拿数据这才是一个干净的抽象。2.1 一个请求在拦截器链路里经过的四个关卡以 axios 为例一个请求从发起到返回会依次经过四个可以被干预的位置请求拦截器、实际的网络传输、响应拦截器的成功分支、响应拦截器的失败分支。401 通常落在失败分支里因为大多数服务端会配合适的状态码返回。但这里有个细节值得注意有些服务端会把错误也包装成 200把真正的业务状态码放在 body 里返回来这种设计下 401 就落在了成功分支拦截器必须同时处理两条路径。请求拦截器的价值在于注入凭证和做前置判断。如果本地根本没有 token或者 token 的过期时间已经明显过去了那这次请求发出去就是浪费一次往返可以在请求拦截器里提前拦下来走刷新流程。响应拦截器的价值在于兜底处理那些发出去才知道过期了的情况。两者配合才能覆盖所有场景。实际链路可以这样理解业务调用 service.get(/order/list) ↓ 请求拦截器注入 Authorization 头 ↓ 网络传输 ↓ 响应拦截器成功分支HTTP 2xx检查业务码 响应拦截器失败分支HTTP 4xx/5xx重点看 401 ↓ 返回给业务调用方这个结构的关键价值是业务层永远只面对成功数据和最终失败两种结果中间的刷新、重试、队列等待全部被拦截器吞掉了。业务代码里不需要出现任何一行if (status 401)。2.2 拦截器拿得到什么拿不到什么拦截器能拿到的东西比想象中多但也有很多拿不到。搞清楚边界能避免写出错误的逻辑。能拿到的原始请求的 URL、method、headers、params、body响应状态码和响应体请求发起的时间戳需要自己加当前实例上挂载的任意自定义字段。这些都存在error.config和error.response里。拿不到的用户当前在哪个页面除非你额外引入路由实例、这次请求在业务上代表什么含义拦截器不知道这是下单还是查询、请求和响应之间服务端到底发生了什么。最后这一点很重要——拦截器不能替服务端做判断它只能基于返回的状态码和错误信息做机械决策。我在error.config上加过一个自定义字段_retry用来标记这次请求已经重试过。这个字段是整套机制能防住死循环的关键因为它让重试过的请求和首次失败的请求可以被区分开。类似的自定义字段我建议都加下划线前缀避免和库本身的字段冲突。2.3 最小可用骨架先能跑再谈优雅在讨论并发和队列之前先把最基础的骨架写出来。这一步不要想着一步到位先做到401 能触发刷新、刷新后能重放跑通之后再补并发控制。import axios from axios; const service axios.create({ baseURL: /api, timeout: 15000, }); // 请求拦截器注入当前凭证 service.interceptors.request.use( (config) { const token localStorage.getItem(access_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) Promise.reject(error) ); // 这里先跳过刷新逻辑下一节补 service.interceptors.response.use( (response) { const res response.data; // 兼容 HTTP 200 但业务码为 401 的软错误 if (res res.code 401) { return Promise.reject({ config: response.config, response, isBusiness401: true, }); } return res; }, (error) { if (!error.response) { return Promise.reject(error); // 网络错误、超时、被取消 } const { status, config } error.response ? error : {}; return Promise.reject(error); } ); export default service;这段代码有三个需要注意的点。第一成功分支返回的是response.data而不是整个response这是个约定后面所有业务代码都按这个约定写别中途改。第二成功分支里主动检查业务码并把软错误转成 reject这样两条路径在业务层看来是一致的。第三!error.response的判断绝对不能省网络断开、请求被取消、CORS 拦截都会走到这里如果不判断就直接读error.response.status会抛出一个新的 TypeError把真正的错误信息盖掉。提示拦截器里不要写console.log打点全部请求尤其不要打完整的 headers。生产构建时这些日志很难彻底清除而 Authorization 头出现在日志里属于明确的安全隐患。3. 刷新 token 的并发合流单飞加等待队列这一节是整篇文章的核心。前面说的七个刷新请求把接口限流触发的问题根因就在这里。当页面上有多个请求同时因为 token 过期而 401 时如果每个失败请求都独立发起一次刷新就会产生 N 次刷新调用。解决它的思路叫单飞single flight同一时刻只允许一个刷新请求在飞其他请求挂起等待它的结果。3.1 三个并发请求同时 401 会发生什么假设页面加载时需要并发请求用户信息、订单列表、消息数量三个接口而此刻 access token 恰好过期。没有并发控制的流程是这样的三个请求几乎同时发出都带着过期的 token。服务端三次都返回 401。拦截器被触发三次各自判断需要刷新各自发起刷新请求。如果服务端采用 refresh token 轮换策略同一个 refresh token 只能用一次第一次刷新成功并作废旧 refresh token后两次刷新就会失败。后两次失败触发登出逻辑用户莫名其妙被踢下线。即使服务端不做轮换三次刷新也是三次无谓的往返而且三次返回的新 token 可能不同最后写入 localStorage 的是哪一次完全取决于时序随机性很大。我在真实项目里见过因为这个问题导致用户在弱网下持续掉线的案例表现是网络一慢就掉登录排查了两天才定位到是并发刷新导致的 token 覆盖。3.2 用 Promise 挂起代替无脑重发解决方案的核心是三个变量加一个数组let isRefreshing false; // 是否有刷新请求正在进行 let refreshPromise null; // 正在进行的刷新 Promise let waitQueue []; // 等待队列 function resolveQueue(token) { waitQueue.forEach(({ resolve, config }) { config.headers.Authorization Bearer ${token}; resolve(service(config)); // 注意这里重新走一遍完整链路 }); waitQueue []; } function rejectQueue(error) { waitQueue.forEach(({ reject }) reject(error)); waitQueue []; }关键点是resolve(service(config))重放的请求要重新走一遍请求拦截器和响应拦截器而不是用保存下来的响应直接返回。前者的好处是重放请求也能享受后续的拦截逻辑比如如果新 token 依然是过期的极端情况下服务端时钟有偏差还能再次进入 401 流程——当然前提是_retry标记能防住无限循环。响应拦截器的完整实现service.interceptors.response.use( (response) { const res response.data; if (res res.code 401) { return handle401(response.config, { response }); } return res; }, async (error) { if (!error.response) return Promise.reject(error); if (error.response.status ! 401) return Promise.reject(error); const config error.config || error.response.config; return handle401(config, error); } ); async function handle401(config, rawError) { // 1. 刷新接口自己失败直接登出绝不再刷 if (config.url config.url.includes(/auth/refresh)) { forceLogout(); return Promise.reject(rawError); } // 2. 已经重试过的请求仍然 401说明刷新没解决问题 if (config._retry) { forceLogout(); return Promise.reject(rawError); } config._retry true; // 3. 已有刷新在进行挂起等待 if (isRefreshing) { return new Promise((resolve, reject) { waitQueue.push({ resolve, reject, config }); }); } // 4. 自己发起刷新成为领飞者 isRefreshing true; try { const newToken await doRefresh(); localStorage.setItem(access_token, newToken); resolveQueue(newToken); config.headers.Authorization Bearer ${newToken}; return service(config); } catch (e) { rejectQueue(e); forceLogout(); return Promise.reject(e); } finally { isRefreshing false; } }doRefresh必须用一个独立的、不带拦截器的 axios 实例来发请求import axios from axios; // 这个裸实例不挂任何拦截器专门用于刷新 const rawRequest axios.create({ baseURL: /api, timeout: 10000 }); async function doRefresh() { const refreshToken localStorage.getItem(refresh_token); if (!refreshToken) throw new Error(NO_REFRESH_TOKEN); const res await rawRequest.post(/auth/refresh, { refreshToken }); if (!res.data || !res.data.accessToken) { throw new Error(REFRESH_INVALID); } if (res.data.refreshToken) { localStorage.setItem(refresh_token, res.data.refreshToken); } return res.data.accessToken; }用裸实例的原因很直白如果刷新请求走的是同一个带拦截器的实例那么这次刷新万一也返回 401拦截器又会去调handle401又去发起刷新无限套娃浏览器直接卡死。我早期就是这么写的本地测试没问题是因为只发了一个请求并发场景下才暴露出来这种 bug 在开发环境里极难复现。3.3 刷新失败之后队列里那些请求怎么收场刷新失败必须把所有挂起的请求都 reject 掉否则那些 Promise 会永远 pending对应的业务代码里的await一直不返回页面就卡在 loading 状态。这是个很容易漏掉的点——很多人只写了成功分支的resolveQueue忘了rejectQueue结果刷新失败时页面表现为半死不活转圈转到天荒地老。同时forceLogout要做得干净利落清掉 localStorage 里的 token、清掉内存中的用户信息、关闭所有进行中的请求如果实例支持、然后跳转登录页并带上当前路径作为回跳参数。清理不彻底会导致下次进入应用时某些全局状态还残留着上一次的用户信息出现换个账号登录还是看到旧数据的诡异现象。3.4 主动续期和被动续期的取舍上面讲的是被动续期等 401 出现再刷新。还有一种做法是主动续期在请求发出前检查 token 的过期时间如果剩余时间小于阈值比如 5 分钟就先刷新再发请求。对比项被动续期主动续期触发时机收到 401 之后请求发出之前额外请求每个用户会多一次重放无依赖服务端返 401 的准确性客户端与服务端时钟一致风险刷新期间请求短暂阻塞时钟偏差导致误判适用场景大多数 Web 应用移动端弱网、时钟可控场景我现在的做法是两者结合请求拦截器里做主动检查剩余时间不足 2 分钟就直接刷新同时响应拦截器保留被动兜底。主动检查能覆盖大部分场景被动兜底处理时钟偏差和服务端提前失效的情况。注意主动检查不要做得太激进如果阈值设成 30 分钟会导致用户每次操作都在刷新反而增加服务端压力。4. 五个真实把线上搞崩的细节前面是主干逻辑这一节讲的是我在实际项目里踩到的具体坑。这些细节在文档里基本找不到但每一个都能让页面白屏。4.1 刷新接口自己返回 401死循环的起点这是最经典的坑。refresh token 也有有效期通常是 7 天或 30 天。用户超过这个时间没打开过应用本地存的 refresh token 早就失效了。这时候触发刷新刷新接口返回 401如果拦截器没有对刷新接口做特殊判断就会再次触发刷新再次返回 401循环下去。浏览器表现为 Network 面板里不断出现新的请求CPU 飙升页面完全无响应。防住它需要三重保险URL 判断、_retry标记、以及刷新用的裸实例。三重里任何一重生效都能避免死循环但三重都加上才够稳。我见过有人只用_retry标记结果因为重放时 config 对象被复制导致标记丢失循环又回来了。URL 判断是最可靠的一层因为它是基于请求路径的硬判断。4.2 HTTP 200 但业务码是 401 的软错误很多国内团队的接口规范是 HTTP 状态码永远返回 200业务状态放在响应体的 code 字段里。这种设计下token 过期返回的是{ code: 401, message: 登录已过期 }HTTP 层是完全成功的。这意味着你的响应拦截器成功分支必须做业务码判断并且判断之后要走的逻辑和失败分支完全一致。为了让两条路径共用代码我的做法是构造一个结构相似的错误对象然后统一交给handle401处理// 成功分支里的软错误 const res response.data; if (res res.code 401) { const fakeError { config: response.config, response, message: res.message || UNAUTHORIZED, }; return handle401(response.config, fakeError); }构造假错误对象时config和response两个字段必须带上因为handle401内部要用到config.url做判断、用config.headers改 Authorization。少带一个就会在下一行报 undefined 错误而且报错位置在拦截器内部业务层的 try/catch 抓不到看起来像请求莫名消失。4.3 下载、SSE、WebSocket 这些不走拦截器的漏网之鱼文件下载是我踩过最坑的一个场景。之前做导出功能用window.open(/api/export?ids...)直接打开下载链接结果 token 过期时浏览器把后端返回的 401 JSON 当成文件下载了下来用户桌面上多了一个叫export的文件打开一看是错误信息。用户以为是导出功能坏了其实是认证失效。正确做法是把下载也走 axios 的 blob 响应类型这样就能享受拦截器的保护async function download(url, params, filename) { const blob await service.get(url, { params, responseType: blob, }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download filename; link.click(); URL.revokeObjectURL(link.href); }不过这里有个新问题blob 响应下如果服务端返回的是 401 JSON它会被包装成 blob 类型拦截器拿到的error.response.data是一个 Blob 对象读不出 code 字段。解决办法是在错误处理里判断data instanceof Blob如果是就把它读成文本再解析if (error.response.data instanceof Blob) { const text await error.response.data.text(); try { const parsed JSON.parse(text); // 用 parsed 里的信息做判断 } catch (e) { // 不是 JSON说明是别的错误 } }SSE 和 WebSocket 也是类似的漏网场景它们不走 axios拦截器完全管不到。SSE 可以用fetch加手动读取流的方式实现这样能自己控制 401 的处理WebSocket 则在连接建立前的握手阶段可能因为凭证失效而失败需要在onerror或onclose事件里单独处理重连和凭证刷新。这些场景我建议统一收进一个请求层的模块而不是散落在各个业务组件里。4.4 多标签页同时刷新localStorage 锁和 storage 事件单页面内的并发问题解决了还有一个更隐蔽的场景用户同时开着五个标签页。每个标签页都有自己独立的内存状态isRefreshing是各自独立的变量。当 token 过期时五个标签页同时发起刷新服务端收到五次刷新请求如果做了轮换只有第一次成功。解决它需要跨标签页通信。最轻量的方案是用 localStorage 存一个刷新锁const LOCK_KEY token_refreshing; const LOCK_TTL 10000; function tryAcquireLock() { const now Date.now(); const raw localStorage.getItem(LOCK_KEY); if (raw) { const { expire } JSON.parse(raw); if (now expire) return false; // 别的标签页正在刷新 } localStorage.setItem(LOCK_KEY, JSON.stringify({ expire: now LOCK_TTL })); return true; }拿到锁的标签页负责刷新其他标签页不刷新而是监听storage事件等待新 token 写入window.addEventListener(storage, (e) { if (e.key access_token e.newValue) { // 其他标签页刷新成功了重放本页挂起的请求 resolveQueue(e.newValue); } });锁必须带 TTL否则某个标签页刷新过程中被用户关掉锁就永远释放不了其他标签页会一直等待。TTL 设成 10 秒比较合适超过了说明刷新大概率已经失败。这个方案不是银弹在极端时序下仍有可能出现重复刷新但把概率从必然降到了极低性价比很高。4.5 跳登录页被触发十次节流与来源记录即使前面所有并发都控制住了还有一类情况会导致重复跳转刷新失败后等待队列里的十个请求全部被 reject如果每个 reject 都触发了登出跳转逻辑用户就会看到页面疯狂闪烁或者路由堆栈里塞了十个相同路径。forceLogout必须做幂等最简单的做法是加一个模块级标记let isLoggingOut false; function forceLogout() { if (isLoggingOut) return; isLoggingOut true; localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); const redirect encodeURIComponent(location.pathname location.search); location.replace(/login?redirect${redirect}); }用location.replace而不是location.href是因为前者不会在历史记录里留下当前页用户登录后按浏览器返回键不会又回到出错页面被踢一次。redirect参数要带上登录成功后能跳回用户原本想去的页面这个细节对用户体验的影响很大尤其是在需要分享深链接的场景下。另外登出时最好先取消所有进行中的请求。如果只是跳转而不取消那些还没返回的请求会在页面卸载后继续跑完然后触发一堆在已卸载组件上更新状态的警告控制台会很脏排查其他问题时容易被干扰。5. 换技术栈之后这套逻辑要改哪几行401 处理的思路是通用的但不同的请求库和运行环境下实现方式差异不小。这里把常见的几种情况列出来避免你在换环境时重新踩一遍。5.1 axios 完整实现把前面的片段整合成一个完整的模块可以直接拿去用import axios from axios; const service axios.create({ baseURL: /api, timeout: 15000 }); const rawRequest axios.create({ baseURL: /api, timeout: 10000 }); let isRefreshing false; let waitQueue []; let isLoggingOut false; function forceLogout() { if (isLoggingOut) return; isLoggingOut true; waitQueue []; localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); const redirect encodeURIComponent(location.pathname location.search); location.replace(/login?redirect${redirect}); } function resolveQueue(token) { waitQueue.forEach(({ resolve, config }) { config.headers.Authorization Bearer ${token}; resolve(service(config)); }); waitQueue []; } function rejectQueue(err) { waitQueue.forEach(({ reject }) reject(err)); waitQueue []; } async function doRefresh() { const refreshToken localStorage.getItem(refresh_token); if (!refreshToken) throw new Error(NO_REFRESH_TOKEN); const res await rawRequest.post(/auth/refresh, { refreshToken }); const data res.data || {}; if (!data.accessToken) throw new Error(REFRESH_INVALID); localStorage.setItem(access_token, data.accessToken); if (data.refreshToken) localStorage.setItem(refresh_token, data.refreshToken); return data.accessToken; } service.interceptors.request.use((config) { const token localStorage.getItem(access_token); if (token) config.headers.Authorization Bearer ${token}; return config; }); async function handle401(config, rawError) { if (!config) return Promise.reject(rawError); if (config.url config.url.includes(/auth/refresh)) { forceLogout(); return Promise.reject(rawError); } if (config._retry) { forceLogout(); return Promise.reject(rawError); } config._retry true; if (isRefreshing) { return new Promise((resolve, reject) { waitQueue.push({ resolve, reject, config }); }); } isRefreshing true; try { const token await doRefresh(); resolveQueue(token); config.headers.Authorization Bearer ${token}; return service(config); } catch (e) { rejectQueue(e); forceLogout(); return Promise.reject(e); } finally { isRefreshing false; } } service.interceptors.response.use( (response) { const res response.data; if (res res.code 401) { return handle401(response.config, { config: response.config, response }); } return res; }, (error) { if (!error.response) return Promise.reject(error); if (error.response.status ! 401) return Promise.reject(error); const config error.config || error.response.config; return handle401(config, error); } ); export default service;有个细节值得单独说service(config)重放请求时config 里还带着_retry: true所以重放后的请求如果再 401会直接走登出。这个标记必须在重放之前就设置好不能等到重放回来再设否则并发场景下两个重放请求会互相覆盖标记。5.2 fetch 封装的等价写法fetch 没有拦截器这个概念需要自己包一层。核心结构是把拦截逻辑放在一个统一的request函数里async function request(url, options {}) { const config { url, ...options }; const token localStorage.getItem(access_token); const headers { ...(options.headers || {}) }; if (token) headers.Authorization Bearer ${token}; const response await fetch(url, { ...options, headers }); if (response.status 401 !config._retry) { config._retry true; if (config.url.includes(/auth/refresh)) { forceLogout(); throw new Error(UNAUTHORIZED); } if (isRefreshing) { const newToken await new Promise((resolve, reject) { waitQueue.push({ resolve, reject }); }); headers.Authorization Bearer ${newToken}; return fetch(url, { ...options, headers }); } isRefreshing true; try { const newToken await doRefresh(); resolveQueue(newToken); headers.Authorization Bearer ${newToken}; return fetch(url, { ...options, headers }); } catch (e) { rejectQueue(e); forceLogout(); throw e; } finally { isRefreshing false; } } if (!response.ok) { const error new Error(HTTP ${response.status}); error.response response; error.config config; throw error; } return response.json(); }要注意 fetch 有一个和 axios 不同的地方fetch 只在网络层出错时 rejectHTTP 401 会走 resolve 分支。所以状态码判断必须写在 resolve 之后不能像 axios 那样放在 catch 里。这个差异导致很多从 axios 迁移到 fetch 的代码在处理 401 时完全失效因为它们在 catch 里写判断而 catch 永远不会被触发。5.3 小程序与 React Native 的差异点小程序里用的是wx.request它有 success 和 fail 两个回调但fail只在网络异常时触发HTTP 4xx 依然走success。所以 401 判断要放在 success 回调里检查res.statusCode 401。小程序也没有 localStorage要用wx.getStorageSync之类的同步 API 替代接口名不同但逻辑一样。React Native 用的还是 fetch所以上面的 fetch 封装可以直接搬。但 RN 环境没有location对象登出跳转需要用导航库的navigate方法或者用一个全局的事件总线把登出事件抛给根组件处理。RN 还有一个额外注意点APP 从后台切回前台时token 可能已经过期很久了建议在 AppState 监听的 change 事件里加一次主动续期检查避免用户切回来看到满屏错误。6. 线上排查 401 的实操路径写完了实现最后聊聊真出事的时候怎么查。上面那些逻辑再严密也总有意料之外的情况有一套固定的排查顺序能省下大量时间。6.1 第一步永远是看 Network 面板的响应头不要上来就翻代码先看请求。打开 Network 面板找到那个 401 的请求重点看四个东西Request Headers 里的 Authorization 是否带上了、值的格式对不对是不是少了 Bearer 前缀、Response Headers 里有没有WWW-Authenticate、Response 内容的形态是 JSON 还是 HTML。这四个信息基本能定位到问题出在哪一层。如果 Authorization 根本没带上问题在请求拦截器或者调用方如果带上了但格式错了问题在拼接逻辑如果有WWW-Authenticate且响应是 HTML说明请求压根没到业务服务是网关或代理拦的如果响应是正常的业务 JSON 错误那就是 token 本身的问题需要看服务端的校验日志。6.2 一张从现象到原因的对照表现象大概率原因验证方式401 后无限刷新刷新接口自身 401 未拦截看 Network 里/auth/refresh的调用次数登录后立刻 401请求拦截器未注入或延迟注入看首个业务请求的 Authorization 头偶发 401刷新后正常多请求并发导致 token 覆盖看同一时刻的请求数量切标签页后 401其他标签页轮换了 refresh token检查服务端是否启用了轮换401 但代码里没走到拦截逻辑业务码 401 走了成功分支看响应体里的 code 字段拿不到 401、只有网络错误CORS 配置不匹配响应被拦截看控制台的 CORS 相关报错下载按钮报 401 变成下载文件通过 window.open 触发的请求用 blob 方式重写下载这张表我基本是贴在工位上的遇到问题先对一遍能过滤掉八成的情况。6.3 我自己踩过的三个具体案例第一个是刷新时重放请求丢 body。当时用了service(config)重放但 config 里的 data 已经被 axios 序列化过又还原了一次导致后端收到的参数结构不对返回 400。排查了很久才发现是重放时数据形态变了。解决办法是在请求拦截器里保留一份原始的_rawData重放时用原始数据。第二个是防抖和重试打架。有个搜索接口做了 300 毫秒的防抖用户快速输入时会取消前面的请求。被取消的请求会走进!error.response分支如果这个分支里也去触发刷新逻辑就会出现一堆莫名其妙的刷新。后来我们在取消的错误里加了单独的判断明确区分被主动取消和真的失败了。第三个是 token 存在内存里。有一版实现为了安全把 access token 只放在内存变量里不放 localStorage。结果页面刷新后内存变量清空但用户还以为自己登录着首个请求没带 Authorization收到 401 触发刷新而 refresh token 在 cookie 里刷新成功了页面正常。看起来没问题但这中间多了一次无谓的 401 和刷新往返每次刷新页面都会发生。后来改成用 sessionStorage 存 access token刷新页面时还能读到就少了这一次往返。这些坑的共同点是它们都不在逻辑主干上而藏在重放取消存储位置这些边角细节里。写 401 处理的时候主干代码半小时就能写完但把边角都填平我在真实项目里前后花了大概两三天而且是被线上问题一个个逼出来的。所以如果你现在正准备把这块重写一遍我的建议是先把并发合流和刷新接口自拦截这两个做扎实剩下的边角可以随着问题出现慢慢补不要一开始就追求完美那样反而容易在主干逻辑上留隐患。
返回列表