☰
登录状态刷新实战:双token+axios拦截器自动续期,告别401踢出
2026/9/28 14:59:59 网站建设 项目流程

做了好几年中后台系统,每次和“登录状态刷新”问题交手都得掉几根头发。尤其是那种用户正在表单里填了一半资料,系统突然弹回登录页,一堆未保存数据全没了的情况,最让人头大。实际上“状态登录刷新”这个问题的核心不只是“token过期”,而是——前端如何预判并自动完成登录态续期,同时不打断用户操作;以及页面刷新时,内存里的登录状态全部清空后,如何快速恢复会话。这篇文章就围绕这两个核心展开,从问题拆解、双token方案、拦截器统一刷新,到多标签页同步,把我在真实项目里踩过的坑和验证过可用的做法,一次说清楚。适合正在处理登录失效续期、401拦截、多页签同步等需求的同学参考。

1. 问题的本质:为什么会出现“状态登录刷新”

先说一个结论:大多数登录态问题都出在“前端把登录状态当成了纯内存数据”或者“把401当成了必须重新登录的信号”。

1.1 登录状态失效的几种典型场景

我自己整理过一份失效场景清单,基本覆盖了日常项目里的绝大多数情况:

  • 短有效期token到期:很多内部系统为了安全把access_token的过期时间设成15分钟或者30分钟。用户习惯一上午不关浏览器,期间任何一个接口返回401,前端如果处理不当,用户就被“静默踢出”。
  • 服务端主动吊销会话:账号被禁用、送审流程里后台强制改密、管理员在另一端踢人,都会让服务端认为当前token失效。
  • 页面停留太久后操作:早上开的系统、下午回来继续用,token已经过期。用户点一个按钮,前端收到401。
  • 多标签页之间的状态错位:一个标签页退出了登录,另一个标签页还傻乎乎地带着旧token请求接口。这个场景最隐蔽,因为从用户视角来看“我明明登录着”。
  • 本地时间与服务器时间不同步:我在一个项目里遇到过用户手机时间快了5分钟,客户端自己校验token时直接认为过期,导致所有请求都带着无效凭证。

把这些问题归到根上,本质上都是:登录凭证存在“生命周期间隙”。就像门锁按时间自动换锁,但用户手里的钥匙是旧的,前端的任务不是把用户拦在外面,而是在换锁那一刻自动把新钥匙递到用户手里。

1.2 状态刷新不只是“重新登录”这么简单

不少人一开始的做法很直接:响应里收到401,就调一下“退出登录”,然后location.href跳转登录页。这套在内部简单后台还能忍,但放到用户量大的产品里完全是灾难——用户正填到一半的工单、刚勾选好的筛选条件、编辑框里没点保存的长文本,全没了。

而且“刷新登录状态”真正的技术难点在于:

  • 401可能发生在任意接口,你不能在每个业务请求里都写一遍刷新逻辑。
  • 多个接口同时返回401时,你不能同时发起多个刷新token请求,否则后端刷新接口会被打爆。
  • 刷新token这个动作本身也可能失败,失败之后要怎么兜底,是一个独立的判断分支。
  • 重放原始请求时,必须保证用的是新token,而不是被请求拦截器再次塞进旧token。

换句话说,状态刷新是一个“跨请求的全局协调机制”,不是某一个接口里的if分支。

2. 主流的登录态管理方案对比

在写拦截器之前,先花点篇幅对比一下方案。因为拦截器只是“执行层”,如果你底层用的还是单token模式,那刷新机制根本无从谈起。

2.1 单token模式:简单直接但续期困难

单token模式就是登录成功后后端返回一个token,前端每次请求带上,token过期就只能重新登录。这个方案在低频率内部工具、后台管理里很常见,优点是后端实现简单,缺点是用户在线体验极差。

我之前维护过一个小型工单系统,token有效期设成2小时,一个上午就有好几个同事来抱怨“填的内容突然没了”。后来我把过期时间调成一整天,虽然抱怨少了,但安全上又心虚——员工的token在公共电脑上一天内都能直接复用。单token模式本质上把“安全性和体验”推到对立面,你很难两头兼顾。

2.2 双token模式:刷新登录状态的核心解法

双token模式解决了这个矛盾。核心思路是拆成两个凭证:

  • access_token:有效期短,一般15分钟到2小时。专门用来请求业务接口,泄露后风险窗口小。
  • refresh_token:有效期长,7天到30天。只用来换取新的access_token,不参与业务接口请求。

我把完整流程拆成四步,方便你画图理解:

  1. 业务请求发出,带上access_token。
  2. 后端发现access_token过期,返回401(或您自定义的特定业务码)。
  3. 前端拦截器拦截到401,自动用refresh_token调用刷新接口。
  4. 拿到新access_token后,把等待中的原请求重新发出去,用户毫无感知。

这个模式里其实有个容易忽略的点:刷新接口本身也要能被“特别对待”。刷新接口不能再触发刷新逻辑,否则会变成递归死循环。所以后面写拦截器时,必须给刷新接口单独开一条不受401拦截逻辑控制的通道。

2.3 方案选型要看项目场景,不能只看“流行”

要不要上双token,我的建议是看四点:

  • 用户在线时长:几分钟内的纯浏览场景没必要上双token;用户一天内长时间挂着的系统一定要上。
  • 数据敏感程度:涉及资金、生产配置、医疗健康这类数据,用短access_token配合refresh_token,即使token被截获,能造成的破坏也有限。
  • 后端改造成本:双token需要后端维护refresh_token的存储和失效机制,如果后端是外包团队一次性交付,建议先评估清楚再上。
  • 是否需要支持跨端无缝登录:如果产品里已经接了统一身份中心,这类服务通常已经管理了会话续期,前端要做的工作是“接入”而不是“自建”。

我把方案选型整理成一个表,方便比照:

维度单token双token(access+refresh)
实现复杂度低中高
用户在线体验过期即踢无感续期
安全风险token泄露后有效期长access短、refresh可吊销
服务端成本低需要维护刷新凭证存储
典型场景内部工具、低频系统中后台产品、面向用户的系统

我自己在绝大多数中后台项目里会选择双token,唯一的例外是那种本身是只读报表类的展示系统,用户停留时间短、交互频次低,单token完全够用。

3. 核心实操:拦截器统一处理401刷新

方案定了之后,真正的重头戏在前端拦截器。这里我用axios举例,因为目前后台项目里axios仍然是最普及的方案;如果你用的是fetch,思路完全一样,只是没有现成的拦截器钩子,需要自己封装一层request函数。

3.1 为什么必须用拦截器而不是在每个接口里处理

你可能会想:“我在请求工具类里封装一个handleAuthError不就行了?”实际上问题在于判断401的时机。如果每个业务页面各自调接口、各自判断错误码,代码会散落得到处都是,而且每个人写出来的风格还不一样。有人只判断HTTP状态401,有人判断业务码是TOKEN_EXPIRED,漏一处就是线上事故。

正确的做法是:让所有业务接口共用同一套axios实例,在响应拦截器里统一拦截401,业务代码完全感知不到token失效这件事。这样做的最大好处是“漏网之鱼”只可能在拦截器里,排查起来只有一个文件。

3.2 代码实现:axios拦截器+请求队列去重

下面这段代码是我在真实项目中精简出来的可用版本,关键逻辑都保留了:

// request.js import axios from 'axios' import { getToken, setToken, clearToken, getRefreshToken } from './auth' const service = axios.create({ baseURL: '/api', timeout: 10000 }) let isRefreshing = false let pendingQueue = [] // 当前请求在刷新期间先挂起,等新token出来了再放行 function subscribePending(config) { return new Promise((resolve, reject) => { pendingQueue.push({ config, resolve, reject }) }) } // 新token拿到后,把挂起队列里的请求全部重放 function replayPending(newToken) { pendingQueue.forEach(({ config, resolve, reject }) => { config.headers.Authorization = `Bearer ${newToken}` service(config).then(resolve).catch(reject) }) pendingQueue = [] } // 请求拦截器:统一塞token service.interceptors.request.use(config => { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:只认401 service.interceptors.response.use( response => response, async error => { const { response, config } = error if (!response) return Promise.reject(error) const status = response.status const bizCode = response.data?.code // 这里用业务码,避免和服务端本身的401状态码混淆 if (status === 401 && bizCode === 'TOKEN_EXPIRED' && !config._retry) { // 已经有刷新请求在跑了,直接排队等待 if (isRefreshing) { return subscribePending(config) } config._retry = true isRefreshing = true try { const refreshToken = getRefreshToken() if (!refreshToken) { throw new Error('refresh_token missing') } // 调用刷新接口,注意这里要用单独的请求,不走service const { data } = await axios.post('/auth/refresh', { refresh_token: refreshToken }) setToken(data.access_token) // 先重放排队中的请求,再重放当前请求 replayPending(data.access_token) config.headers.Authorization = `Bearer ${data.access_token}` return service(config) } catch (refreshError) { // refresh_token也失效,清空登录态回登录页 clearToken() // 记录当前页面路由,登录成功后再跳回来 localStorage.setItem('redirect_after_login', window.location.href) window.location.href = '/login?reason=expired' return Promise.reject(refreshError) } finally { isRefreshing = false } } return Promise.reject(error) } ) export default service

这里有两个细节值得展开。

第一个细节:刷新接口不能用service实例调用。因为如果你用了同一个实例,刷新接口返回401时,会被响应拦截器再拦截一次,从而再次触发刷新逻辑,造成无限递归。实际项目中我见过把刷新请求写成service.post('/auth/refresh')然后页面直接卡死的案例,排查了半天才发现是这个原因。

第二个细节:为什么要引入isRefreshing布尔值和pendingQueue队列。假设用户在一个页面上同时发出了5个请求,它们都带着过期token,服务端全部返回401。如果没有isRefreshing,5个请求都会各自去调刷新接口,后端刷新接口瞬间收到5个相同刷新请求,不仅浪费流量,还可能因为刷新凭证轮换机制,导致其中一个成功后其他4个全部失效。有了isRefreshing标志,第一个401触发刷新,剩下4个全部进入等待队列,等新token回来后统一重放,刷新接口只被调用一次。

3.3 刷新后如何安全重放原始请求

重放这一步是很多新手容易写错的地方。几个关键点:

  • 必须给原始请求打一个_retry标记。如果不打标,重放后如果新token仍然被服务端判定401(比如refresh_token本身已经失效,但被某个中间层放行了),就会再次进入刷新逻辑,形成死循环。所以每次刷新重放前都要判断!config._retry。
  • 重放请求要重新走service(config),而不是裸调用axios(config)。因为裸调用绕开了你设置的统一拦截器,如果接口后续又出现401,就得不到统一处理。只有继续走service,整个链路才是闭环的。
  • 请求头里的Authorization要覆盖,而不是追加。如果你用config.headers.Authorization = ...,那是覆盖,没问题;如果你用config.headers.common['Authorization'] = ...或者直接push,就可能导致最终发出两个Authorization头,后端解析大概率报错。

我在实际操作中还踩过一个更隐蔽的坑:请求拦截器里已经用某个变量缓存了旧token,重放请求经过请求拦截器时,又把旧token覆盖上去了。所以建议——请求拦截器里不要存死值,用getToken()方法实时读取当前token。

4. 多标签页与“刷新页面丢登录态”的同步问题

拦截器解决的是“token过期时自动续期”,但“状态登录刷新问题”还有另一个非常普遍的表现——用户刷新浏览器页面后,登录状态“看起来丢了”。

4.1 刷新页面时用户信息丢失如何恢复

原因其实很简单:Vue/Pinia或React/Redux这类状态管理库,默认数据都存在内存里。按下浏览器刷新键的瞬间,内存被清空,store.userInfo变为空,但localStorage里的token还在。结果就是:路由守卫判断“有token,放行”,但页面一进来,拿不到用户信息,头像、昵称、权限菜单全空白。

恢复思路并不复杂:token持久化到localStorage,userInfo也要做分层缓存。

我习惯的做法是:

  1. 登录成功后把token、refresh_token、用户基础信息(id、昵称、头像等)写入localStorage。
  2. 在应用初始化时,路由守卫里先判断本地token是否存在。
  3. 如果存在,调用fetchUserInfo()接口拉取最新用户信息,而不是直接信任本地缓存。
  4. 拉取成功后写入store;拉取失败则清理登录态并跳转登录页。

这里有个权衡:用户基础信息里,像id、昵称这类非敏感信息可以用本地缓存先顶一下,页面秒开;但权限点、角色这类会随时变化的敏感信息,必须通过接口实时拉取,否则权限变更后旧缓存会被读到刷新前。

4.2 多标签页强制下线与登录状态同步

多标签页是“状态登录刷新”问题里最折磨人的场景。用户开了两个标签页,在标签页A点了退出登录,标签页B里的状态还是登录态,页面数据依然正常展示。直到标签页B某个接口返回401,才开始处理,而且很多实现里此时已经是“静默退出”,用户根本不知道发生了什么。

解决多标签页同步,我推荐“本地存储监听”方案。利用storage事件,当一个标签页修改了localStorage时,其他标签页会收到通知:

// 多标签页监听:登录态被清理时,本页同步退出 window.addEventListener('storage', (event) => { if (event.key === 'login_state') { const state = JSON.parse(event.newValue || 'null') if (state && !state.isLoggedIn) { clearAuth() window.location.href = '/login?reason=kicked' } } })

写这段代码时有三个细节:

  • storage事件只在“其他标签页”触发,本页不会触发。所以本页退出登录时,你还得在自己的退出逻辑里主动清一次login_state,并跳转登录页。
  • 不能用sessionStorage代替localStorage。因为sessionStorage是每个标签页独立的,A页修改B页根本收不到事件。只有localStorage是跨标签页共享的。
  • 监听的范围要精确,别监听整个localStorage所有key的变化。否则甚至可能有别的代码设置一个无关key,你的监听器就跑一遍退出逻辑,误杀情况很尴尬。

如果项目对实时性要求更高,还可以考虑BroadcastChannelAPI。它专门用于同源页面之间的消息通信,比storage事件的体验更像“前端消息总线”,能传更丰富的数据,比如“token已在标签页A刷新,这是新token,请B页同步更新”。不过记得在页面卸载时channel.close(),否则会内存泄漏。

4.3 登录状态展示与全局状态恢复

还有一个钝刀子割肉的问题:页面没有刷新,但token已经过期了,用户停留在当前页面什么也不做,系统不跳转也不能自动续期。等用户点击一下,401才回来。很多实现此时直接把用户甩回登录页——这种体验很突兀。

我更推荐的做法是做一个“会话过期遮罩层”。拦截器在捕获到401但refresh_token没有完全失效时,可以先尝试静默刷新;如果刷新失败,不要立刻跳转,而是弹一个全屏蒙层,提示“登录状态已过期,请点击按钮刷新”,用户点击后重新走一遍登录流程,登录成功后再回到之前停留的页面。

这个方法在那些“用户可能停在一个页面很久”的编辑器、工单填写、审批流应用里特别实用。我做过一个巡检工单应用,巡检员常常一个页面打开放在户外大半天,期间网络短暂断开,回来时token早已失效。如果直接踢到登录页,之前填的巡检记录可能没提交就丢失了;用遮罩层方案,至少能提醒用户当前会话已失效,且不强制清空界面现场。

5. 常见问题与排查技巧速查

这部分是实战踩坑的集合,我把这几年里出现频次最高的问题列成一张排查表,方向性排查时可以直接对着看。

现象可能原因排查思路
刷新token死循环,接口不断被打刷新接口自身也走了拦截器,触发递归刷新检查刷新请求是否独立于service实例;另外刷新接口返回码不要复用业务401,建议用独立码
多个并发请求触发大量refresh请求没有做并发去重,每个401各自刷新确认isRefreshing标志是否生效,观察Network面板是否存在同时间多个refresh请求
刷新成功但重放请求仍带着旧token队列里的config头部没有在重放前更新,或请求拦截器用旧值覆盖在replayPending里必须显式给每个config.headers赋值;请求拦截器一律通过getToken()实时取值
页面刷新后store里的用户信息丢失store是内存数据,刷新即清空做localStorage持久化+应用初始化时重新拉取用户信息
多标签页退出不同步没有跨标签页通信机制用storage事件或BroadcastChannel监听登录态变化
时间不同步导致token“提前过期”客户端本地时间比服务器时间快登录校验不要依赖本地时间,后续请求凭服务端返回值判断,前端不自行计算过期时间
refresh_token接口返回401但页面没反应刷新失败分支没被处理确认刷新catch分支里是否有清理登录态和跳转逻辑,且跳转前保留回跳路径

我实际排查问题时有个固定动作:打开Network面板,按下刷新按钮,盯着第一个401请求看它的响应内容。几乎所有问题都能从这里找到线索。比如如果同一个刷新接口在几秒内被调用了多遍,那就是并发去重失效;如果刷新接口返回200但AccessToken没更新,那就该找后端问题;如果308重定向导致POST变成GET,那是后端接口路径少了个斜杠,和前端无关。

还有一个小技巧是给拦截器加临时的日志开关。不要直接删代码,用环境变量控制打印:

if (isRefreshing) { if (process.env.NODE_ENV === 'development') { console.warn('[auth] token刷新中,请求排队等待', config.url) } return subscribePending(config) }

上线前检查一遍日志级别,线下调试时信息一目了然,这个方法我用了很多年。

6. 踩了几次坑之后的个人体会

分享几个我从真实事故里总结出的原则,写在这里,算是给自己留个备忘录。

第一,token失效只认一个统一信号。我经历过一个项目,后端有的接口返回HTTP 401,有的返回200但业务码是TOKEN_EXPIRED,还有的返回AUTH_FAILED。前端拦截器写得像拼图一样,到处打补丁,最后还是漏了一个接口没覆盖。后来我强制和服务端约定:只要token失效,一律HTTP 401+统一业务码,前端只认这一种信号,其他一律当普通业务错误处理。

第二,刷新动作必须全局唯一。不管是双token还是对接统一身份中心的隐式续期,同一时间只能有一个“会话续期”动作在跑。用isRefreshing布尔值可以,更稳的写法是把refreshTokenWithLock设计成一个共享Promise,让所有并发请求都在同一个Promise上等待:

let refreshPromise = null function refreshTokenWithLock() { if (!refreshPromise) { refreshPromise = doRefreshToken().finally(() => { refreshPromise = null }) } return refreshPromise }

这样做的好处是,即使中间某个请求等不及抛了异常,其他请求依然能通过这个共享Promise等到新的token。

第三,重放请求必须能识别自己是“重放”。我见过最严重的线上事故是重放请求再次进入401分支后,又触发一次刷新,然后刷新token被后端吊销,导致连环注销。所有请求重放前必须打一个防重入标记,拦截器判断到这个标记就不要再进刷新分支。

第四,本地缓存用户信息要克制。能缓存id、昵称这类基础字段,权限点、角色、员工编号这类动态数据必须实时拉取。我早期图省事,把登录后返回的完整用户对象全量存进localStorage,结果权限调整后用户要手动清缓存才能看到新菜单。后来改成每次“页面刷新”或“应用启动”时重新拉取用户信息,问题才彻底解决。

最后分享一个我一直保留的小设计:在刷新失败跳转登录页前,把当前页面完整路由存到localStorage,登录成功后再从登录页跳回来。这个细节成本极低,但对用户的体感提升特别明显。之前做审批流系统,评审员填了一堆审批意见,token过期弹回登录页,重新登录后还能回到原来的审批页面继续提交,而不是从首页重新找入口。很多人可能觉得这只是一个小体验点,但我在实际项目里发现,“能回到原来的页面”这个需求的反馈量一直很高。状态登录刷新问题本身就是一堆细节的组合,把每一个细节都处理好,用户才真的感受不到登录态的存在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询