ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

前端Token无感刷新机制详解:双Token与Axios拦截器实现

前端Token无感刷新机制详解:双Token与Axios拦截器实现 1. 项目概述为什么前端需要一个无感刷新机制1.1 从一次尴尬的用户体验说起先说个场景。用户在后台管理系统里填了一长串表单刚点保存前端忽然弹出一个登录状态已过期请重新登录所有录入的数据全部白费。又或者用户正在浏览列表页翻到第 6 页点击下一瞬间直接跳回登录页。这种体验有多劝退做过 B 端产品的朋友都懂。本质原因是大部分系统的登录态都依赖 Token 机制而 Token 是有时效性的。为了保证安全服务端通常把 Access Token 的生命周期定得很短比如 15 分钟到 2 小时不等。如果你只用这一个 Token用户每隔一段时间就会被强制打断一次。项目上线后产品经理、用户甚至你自己都会觉得这个系统怎么老是让我重新登录。这就是标题里说的token无感刷新要解决的痛点让用户在完全没有感知的情况下完成身份凭证的续期保持登录状态连续且安全。说白了像我们在某些大型平台 App 里连续逛了一下午不会要求你每隔半小时重新登录一次靠的就是这套机制在前端后台静默完成续期。在做这个方案之前我建议你先搞清楚一件事前端做无感刷新并不是前端自嗨而是需要后端配合的双 Token 机制。也就是说你至少需要两个凭据一个用于日常请求鉴权一个用于续命。1.2 双Token机制Access Token与Refresh Token的分工先理清两个角色的分工Access Token访问令牌短期有效通常是 15 分钟到 2 小时。每次请求都带上它服务端验证通过才放行。它可以放在请求头里相对暴露风险可控因为就算泄露过期时间短危害面有限。Refresh Token刷新令牌长期有效通常是 7 天到 30 天有的产品甚至是记住我的 30 天免登录。它唯一的用途就是换取新的 Access Token不参与普通的业务请求。流程上也非常像银行卡密码和支付密码的分工你平时用银行卡消费不需要反复输支付密码但一旦支付密码过期或丢失就需要用更底层的身份凭证去重置它。无感刷新的核心逻辑是当发现 Access Token 过期时通常以接口返回 401 为信号前端自动用 Refresh Token 去请求一个新的 Access Token拿到后把刚刚失败的请求重新发一次。整个过程中用户完全无感知。这个逻辑看着简单但真正落地时有一堆细节要处理并发请求、刷新并发锁、多标签页同步、刷新失败降级。这些都是项目里容易翻车的地方也是本文想重点拆解的。2. 核心设计思路从拦截器到请求队列2.1 请求拦截器给每个请求戴上通行证无感刷新的第一步是让所有请求都自动携带 Access Token。如果项目使用了 Axios 这类 HTTP 客户端通常会统一封装一个带 interceptors 的实例请求发出前从本地存储中读取 Token写入 Authorization 头。这一步大家都会写但有几个细节容易被忽略不要把 Token 存入 localStorage 之后就到处直接读建议封装一个统一管理 Token 的模块所有读写都走这个模块。后期如果要改成内存存储、cookie 存储或者加密存储只需要改这一个模块。在请求拦截器里要放行刷新 Token 的接口本身。否则会出现一个死循环刷新请求也带上了过期 Token又把刷新接口打挂了。部分接口可能不需要鉴权比如登录、验证码、第三方回调类接口需要有一个白名单机制让这些请求不携带 Token也不参与 401 后重放逻辑。这里我习惯的做法是在封装的 request 函数上留一个可选的配置项类似skipAuth: true方便业务侧灵活控制。// 简单示意请求拦截器 service.interceptors.request.use((config) { if (config.skipAuth) { return config } const accessToken tokenStore.getAccessToken() if (accessToken) { config.headers.Authorization Bearer ${accessToken} } return config })2.2 响应拦截器401不是终点而是中转站响应拦截器是整个机制的心脏。当接口返回 401不能直接弹登录过期而是要区分场景业务接口返回 401说明 Access Token 过期触发刷新流程。刷新接口本身返回 401说明 Refresh Token 也过期了只能走强制登录。部分特殊业务接口本身就有 401 语义比如无权限查看某条数据这种情况不能和Token 过期混为一谈。建议后端在返回 401 时带上一个业务错误码前端精确判断后再决定是否进入刷新流程。这里有一个很重要的点401 只是最常见的过期信号但 Token 过期不一定发生在请求时。比如用户挂着一个页面很久没操作然后点击了一个按钮请求发出后才发现过期了这已经是一次交互不可避免会产生一点延迟。如果你的系统对体验要求更高可以做定时提前刷新也就是说在 Access Token 还有 5 分钟要过期的时候前端主动用 Refresh Token 去换新这样用户点击的时候 Token 一直是有效的根本不会走到 401 那一步。后面第 3 节我会专门讲这个方案。2.3 并发请求的单点刷新策略假设用户在一个页面同时发起了 5 个接口请求恰好这一刻 Access Token 全部过期了。如果每个请求在收到 401 后都各自去调用刷新接口那就会出现 5 个并发的刷新请求既浪费网络资源又可能因为后端对刷新接口有一次性消费逻辑导致多个刷新请求互相覆盖最终反而失败。正确的做法是单点刷新多个 401 响应到达后只让一个请求去执行刷新其余请求先挂起等刷新完成后再重放。这有点像公司统一充电大家发现手机没电一个人去取充电宝其他人排好队等着充。常见实现是维护一个全局的refreshPromiselet refreshPromise null function handleRefreshToken() { if (refreshPromise) { return refreshPromise } refreshPromise refreshApi() .then((res) { tokenStore.setTokens(res.accessToken, res.refreshToken) return res.accessToken }) .finally(() { refreshPromise null }) return refreshPromise }同时所有请求在发现 401 后把原请求的配置缓存到队列里等refreshPromise成功后再批量重放。这里最需要注意的一点是重放的是原请求的 config而不是重新发一个新的业务请求否则可能导致业务逻辑被执行两次比如重复提交订单。下面我用一个表格来总结三种处理策略的差异策略并发刷新重放方式适用场景每个请求独立刷新是存在雪崩风险自身重新请求不推荐单点刷新 队列重放否只发一个刷新请求缓存原 config 重放推荐大多数中后台适用提前定时刷新否由定时器统一触发无 401 场景推荐体验最佳3. 实操详解基于Axios的完整实现3.1 基础封装请求实例与鉴权状态下面是一段比较完整的基于 Axios 的无感刷新实现。这段代码我在多个中后台项目里反复调整过基本覆盖了日常使用的大部分场景。import axios from axios const tokenStore { accessToken: null, refreshToken: null, // 建议在生产项目中用独立模块 storage 持久化 getAccessToken() { return this.accessToken || localStorage.getItem(access_token) }, getRefreshToken() { return this.refreshToken || localStorage.getItem(refresh_token) }, setTokens(accessToken, refreshToken) { this.accessToken accessToken this.refreshToken refreshToken localStorage.setItem(access_token, accessToken) localStorage.setItem(refresh_token, refreshToken) }, clear() { this.accessToken null this.refreshToken null localStorage.removeItem(access_token) localStorage.removeItem(refresh_token) } } const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器自动携带 Access Token service.interceptors.request.use((config) { if (config.skipAuth) return config const token tokenStore.getAccessToken() if (token) { config.headers.Authorization Bearer ${token} } return config })这里请大家注意我在 tokenStore 里同时维护了内存变量和 localStorage。原因是内存变量读取快、不易被其他脚本直接翻到而 localStorage 可以跨页面共享刷新页面后 Token 不丢。两个配合使用是实际项目里比较稳妥的做法。3.2 核心代码刷新逻辑与请求重放机制响应拦截器是重头戏。let isRefreshing false let waitQueue [] // 刷新 Access Token 的接口调用需要自己实现通常位于 /auth/refresh async function requestRefreshToken() { const resp await axios.post(/auth/refresh, { refreshToken: tokenStore.getRefreshToken() }, { skipAuth: true }) return resp.data } service.interceptors.response.use( (response) response, async (error) { const { response, config } error // 请求超时、断网等情况不做刷新 if (!response) { return Promise.reject(error) } // 刷新接口自身失败不进入刷新流程 if (config.url.includes(/auth/refresh)) { tokenStore.clear() redirectToLogin() return Promise.reject(error) } // 业务接口里偶发 401但不属于 Token 过期 if (response.status 401 config.skipAuth) { return Promise.reject(error) } // 明确是 Access Token 过期 if (response.status 401 !config._retried) { // 防止同一个请求被重试多次 config._retried true if (isRefreshing) { // 已经有人在刷新了把当前请求挂到队列里 return new Promise((resolve, reject) { waitQueue.push({ config, resolve, reject }) }) } isRefreshing true try { const data await requestRefreshToken() const { accessToken, refreshToken } data tokenStore.setTokens(accessToken, refreshToken) // 重放队列里所有挂起的请求 waitQueue.forEach(({ config: pendingConfig, resolve, reject }) { pendingConfig.headers.Authorization Bearer ${accessToken} service(pendingConfig).then(resolve).catch(reject) }) waitQueue [] // 重放当前请求 config.headers.Authorization Bearer ${accessToken} return service(config) } catch (refreshError) { waitQueue.forEach(({ config: pendingConfig, resolve, reject }) { reject(refreshError) }) waitQueue [] tokenStore.clear() redirectToLogin() return Promise.reject(refreshError) } finally { isRefreshing false } } return Promise.reject(error) } )先解释几个关键变量isRefreshing全局刷新锁。避免并发 401 同时触发多次刷新接口。waitQueue等待队列。在刷新过程中到达的 401 请求都会把原 config 和 Promise 的 resolve/reject 存进去刷新完成后统一重放。config._retried防止同一个请求因为 401 反复进入刷新流程。注意在刷新完成后又要对当前请求重放一次所以重试标志要放在最上面判断。关于重放这个动作有两点经验想分享建议调用service(config)而不是axios(config)这样重放请求依然会走拦截器Header 自动更新同时也能复用统一的错误处理。如果请求方法不是 GET要特别小心重放带来的副作用。比如用户提交了一个订单请求到达后端但响应因为网络原因前端只收到了 401这时重放可能会造成订单重复创建。这种场景最好在后端做幂等性设计或者在前端对关键写操作做仅刷新不复放的特殊处理提示用户手动确认。3.3 定时主动刷新在过期前未雨绸缪拦截器方案有一个天然的小缺陷用户某次操作触发请求时才发现 Token 过期了虽然刷新很快但这次请求理论上已经慢了一拍。对于敏感操作支付、提交、删除类操作如果希望做到零停顿可以使用定时主动刷新。核心思路在 Access Token 过期前的某个安全时间点比如还剩 5 分钟时用 Refresh Token 提前换取新 Token。function scheduleTokenRefresh(expiresIn) { // expiresIn 是后端返回的 accessToken 有效期单位 ms const THRESHOLD 5 * 60 * 1000 const delay Math.max(0, expiresIn - THRESHOLD) setTimeout(async () { try { const data await requestRefreshToken() tokenStore.setTokens(data.accessToken, data.refreshToken) // 递归安排下一次刷新 scheduleTokenRefresh(data.expiresIn) } catch (error) { // refreshToken 也失效时转入被动刷新逻辑或退出登录 redirectToLogin() } }, delay) }使用主动刷新后大部分日常操作不会再遇到 401只有极端情况例如长时间断网或者设备休眠导致定时器没执行才会触发拦截器的兜底刷新。这里要注意定时器在后台标签页可能被浏览器节流所以不能完全依赖主动刷新拦截器兜底逻辑仍然要保留。4. 多标签页与边界场景躲不开的硬仗4.1 多标签页的token同步难题用户在一个标签页操作Access Token 刷新了另一个标签页里还存着旧 Token过几分钟后这边请求也 401 了。如果另一个标签页自己不处理用户就会遇到明明没干什么却被强制下线的错觉。解决方案有很多层方案一每次都从 localStorage 读 Token并且配合storage事件监听。localStorage 在同一浏览器的不同标签页之间是共享的当一个标签页写入新 Token其他标签页监听storage事件后更新内存中的 Token。方案二使用 BroadcastChannel。通过命名频道广播 Token 更新消息比 storage 事件更专注、更可控。方案三让后端把 Access Token 设成短书 前端在主标签页维持唯一刷新者。两个标签页都收到 401 时用一个约定好的 localStorage 锁例如refresh_lock决定谁去刷新另一个等待更新。这个方案复杂度最高一般不推荐。我实际项目中用得最多的是第一种内存 localStorage storage 监听。原因很简单代码侵入小而且大多数中后台项目的标签页不会复杂到需要专门的状态管理。window.addEventListener(storage, (event) { if (event.key access_token) { tokenStore.accessToken event.newValue } if (event.key refresh_token) { tokenStore.refreshToken event.newValue } })4.2 刷新失败的降级处理Refresh Token 也有自己的有效期更麻烦的是某些安全策略还会要求刷新令牌可轮换每次刷新成功后后端返回一个新的 Refresh Token同时作废旧的那个。如果两个标签页几乎同时触发刷新旧的 Refresh Token 已经被一个标签页换掉了另一个标签页带着旧 Refresh Token 去请求就会失败。对于这种情况处理策略是前端不要在每个标签页各自刷新优先使用前面提到的 storage 监听让后到的标签页先等一等。当刷新接口返回 401 或相关错误码时不要立刻让用户再登录一次先尝试静默清理本地 Token 等待用户下一次操作时引导登录。有些用户可能只是开着一个废弃标签页清理掉他本地的登录态也不会损失什么。关键页面如支付页、信息提交页建议在入口处主动做一次 Token 有效性校验把错误提前暴露而不是等到提交动作才失败。4.3 与第三方SDK、iframe集成的注意事项如果项目页面里嵌入了 iframe 内的第三方应用或者接入了需要独立鉴权的 SDK这些场景往往不会走你封装的 Axios 实例。你需要在收到第三方 401和自己更新 Token之间建立一套消息通信机制。简单做法是父页面监听 iframe 上传的 auth 事件例如 iframe 内业务检测到 401 时向父页面发布消息父页面触发刷新后把新 Token 通过 postMessage 传达给 iframe。这里要注意 postMessage 的安全校验务必要校验消息来源否则会把 Token 泄露给未知站点。这不是危言耸听我在真实项目里就见过开发图省事不做来源校验最后被安全测试打回。5. 安全与性能无感刷新的另一面5.1 Token存放位置的权衡Token 存储位置这个话题每次聊都能吵很久。我根据实际项目经验给个结论Access Token可以放内存或 sessionStorage。如果放 localStorage要接受同源下任意脚本都能读的 XSS 风险如果放内存页面刷新即丢失需要配合刷新接口和持久化方案一起设计。折中方案是放内存 初始化时异步恢复。Refresh Token更安全的位置是 httpOnly Cookie由后端 Set-Cookie 写入这样 JS 根本读不到能防止 XSS 窃取。但这要求后端接口支持 Cookie 传递且前端不能再用从 localStorage 读取后放 Header的方式去调刷新接口。实践中很多团队为了简单把两个 Token 都放 localStorage这在内部管理系统里可以接受但如果做面向公网的真实产品建议至少把 Refresh Token 放在 httpOnly Cookie 中。前端调用刷新接口时浏览器自动带上 Cookie后端验证后直接返回新的 Access Token这个方案对前端的改动很小但对安全性的提升非常明显。5.2 刷新流程的防重放设计再强调一遍刷新接口的幂等性非常重要。如果后端对 Refresh Token 做轮换每次刷新后旧 Refresh Token 失效那前端的刷新锁就绝对不能漏。任何并发刷新都会导致其中一个请求必然失败。实现时要注意isRefreshing标志要用全局作用域不能放在某个请求实例内部。刷新请求发出后所有后续 401 请求都进队列不能有一个漏网之鱼继续触发刷新。Promise 的finally一定要重置isRefreshing否则一次刷新之后后面所有请求都进队等待永远不会重放导致页面假死。有一次我排查一个线上问题现象是第一次操作没事第二次操作整个页面所有请求全部卡住最后发现就是某个分支没有在finally里复位isRefreshing刷新锁一直卡在 true队列里的请求越积越多。另外可以考虑在刷新请求头里额外加一个来源标识如页面标识后端可据此区分不同来源的刷新请求降低被撞库刷接口的风险。这个属于安全加固项看后端同学愿不愿意配合。6. 常见问题速查表与排障思路问题现象可能原因处理建议刷新接口被反复调用isRefreshing没生效或并发 401 没有进队列核验刷新锁是否在全局作用域检查是否有分支绕过进队逻辑刷新成功后原请求不返回队列重放时没有调用resolve或者service(config)返回的 Promise 没有和原 Promise 关联打印队列长度确认 request 的 resolve/reject 被正确触发登录页反复跳转与刷新死循环刷新接口本身返回 401 后又被业务拦截器当成普通 401 处理必须在刷新接口的请求配置上标记skipAuth并在拦截器里对刷新接口放行一个标签页刷新后另一个标签页仍报 401多标签页 Token 不同步使用 storage 事件或 BroadcastChannel 同步 Token刷新成功后某些请求依然带旧 Tokenconfig对象被复用Header 更新没生效重放前显式赋值config.headers.Authorization Bearer newToken页面在后台挂机很久后 Token 过期定时器被浏览器节流保留请求拦截器兜底不要只依赖定时刷新用户已经登录但部分页面请求 403首页 Token 正常但后端验权区分了角色权限区分 401 与 403403 通常与权限有关不应触发刷新流程排障时的核心技巧就一条把 Token 的获取、刷新、清理全部日志化输出。我一般在开发环境开启 debug 模式在每个关键节点 console 一行状态如[auth] start refresh、[auth] token updated、[auth] retry request count: 3。这个习惯帮我快速定位了大量偶现问题也方便前后端联调时定位到底是哪一端没有按照约定逻辑执行。7. 写在最后的经验心得无感刷新看起来是一个小功能但和登录态、安全策略、并发控制、多页面通信都有关联属于麻雀虽小五脏俱全的典型前端工程化问题。我做了十几个带登录体系的前端项目后最大的体会是把拦截器和队列逻辑写得再漂亮也不如提前和后端把刷新接口的语义约定清楚。我这里说的是几个关键约定刷新接口返回的数据结构是新旧 Refresh Token 一起返回还是只返回 Access Token旧 Refresh Token 是否一次性作废401 是专属于登录态过期还是也会出现在无权限操作场景刷新接口是否需要独立的鉴权方式还是明文通过 Cookie 携带这几个问题不聊清楚前端代码写多少层都救不回来。最后再分享一个小技巧如果你的项目参与人数多、接口消费方多建议把 Token 相关逻辑包括存储、刷新、队列、事件监听单独抽成一个模块并加上注释。不要把它散落在各个页面的工具函数里。这是我在重构中后台项目时最常说的一句话认证逻辑应该是全局的、唯一的、可替换的。把它做干净了后续无论是接第三方登录、换 JWT 方案、或者兼容更多移动端场景都会轻松很多。
RELATED READING

延伸阅读

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