在上一篇文章《[我的项目实战(十)] 现代 Web 鉴权体系:从登录到双 Token 机制的全流程实践》中,我们完整梳理了后端如何通过 JWT + Refresh Token 构建安全、可扩展的身份认证系统。但一个完整的鉴权闭环,不仅需要服务端的设计,更离不开前端对 token 生命周期的精细管理。
今天这篇文章,我们将聚焦于那个“用户正在写笔记,突然请求失败跳回登录页”的经典痛点,深入探讨:前端如何实现真正意义上的“无感刷新”?
我们将一步步解决并发控制、请求重试、状态同步等一系列工程难题,构建出一套稳定、低侵入、适用于真实项目的响应拦截机制,并解析其背后的设计哲学。
一、问题的本质:401 不等于“请登录”
当我们使用 JWT 做身份认证时,总会遇到这样一个场景:
用户 A 登录成功,拿到有效期为 15 分钟的
access_token。
第 16 分钟,他点击保存草稿,接口返回401 Unauthorized。
此时,如果直接弹窗提示“登录已过期,请重新登录”,体验是非常割裂的——用户明明一直在操作,凭什么说没就没?
但如果我们能在这背后悄悄完成一次 token 刷新,并把刚才失败的请求自动重试,整个过程用户完全无感知,这就叫 “无感刷新”。
而实现它的关键,在于正确理解 401 的语义差异:
| 场景 | 含义 | 应对策略 |
|---|---|---|
| access_token 过期 | 可恢复的状态异常 | 自动刷新,重试请求 |
| refresh_token 失效 / 用户登出 | 身份彻底失效 | 引导重新登录 |
我们的目标,就是精准识别第一种情况,并在不干扰用户的前提下完成自救。
二、初步尝试:最简单的刷新逻辑
先来看一个朴素版本:
axios.interceptors.response.use(
res => res.data,
async error => {
const { config, response } = error;
if (response.status === 401) {
// 尝试刷新 token
const newToken = await refreshToken();
// 修改原请求 header
config.headers.Authorization = `Bearer ${newToken}`;
// 重新发送
return axios(config);
}
return Promise.reject(error);
}
);
看起来没问题?其实隐患重重。
❌ 问题 1:并发请求导致多次刷新
假设页面同时发出三个请求 A、B、C,它们几乎同时因 token 过期返回 401。那么这三条都会进入上述逻辑,结果是:
- 三次调用
/auth/refresh - 可能触发服务端防重放机制导致后续失败
- 最终状态混乱
❌ 问题 2:B 和 C 请求永远丢失
A 开始刷新,B 和 C 不应该立即重试,而应等待 A 拿到新 token 后再继续。否则会出现:
- B 在 A 完成前就发起重试 → 仍用旧 token → 再次 401
- 陷入无限循环或重复刷新
所以,我们必须引入一种“协调机制”。
三、核心设计:锁 + 队列模式
为了应对并发与依赖关系,我们采用经典的 “锁 + 请求队列” 模式。
1. 全局状态定义
let isRefreshing = false; // 是否正在刷新 token
let requestsQueue: Array<(token: string) => void> = []; // 待处理请求队列
isRefreshing是一把互斥锁,确保同一时间只有一个 refresh 流程在执行。requestsQueue存放那些需要等待新 token 的请求。
2. 拦截器中的分流处理
当某个请求遇到 401:
if (response.status === 401 && !config._retry) {
if (isRefreshing) {
// 已有刷新任务在进行,当前请求加入队列等待
return new Promise(resolve => {
requestsQueue.push((token: string) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(axios(config));
});
});
}
// 否则由我来主导刷新
isRefreshing = true;
config._retry = true;
try {
const { access_token } = await axios.post('/auth/refresh', {
refresh_token: getRefreshToken()
});
// 更新全局状态
setAuthToken(access_token);
// 执行队列中所有等待的请求
requestsQueue.forEach(callback => callback(access_token));
requestsQueue = [];
// 重发当前请求
config.headers.Authorization = `Bearer ${access_token}`;
return axios(config);
} catch (e) {
logout();
redirectToLogin();
return Promise.reject(e);
} finally {
isRefreshing = false;
}
}
这个结构看似复杂,实则层次清晰:
- 判断是否首次触发:避免重复刷新;
- 如果是竞争者,则排队等候:利用闭包保存请求上下文;
- 如果是发起者,则承担刷新职责:获取新 token、通知队友、重发自己;
- 统一清理资源:释放锁,防止死锁。
四、关键技术点剖析
1. _retry 标志位的作用
你可能会问:为什么需要 _retry?
设想没有它,会发生什么?
- 请求 A 收到 401,开始刷新流程;
- 刷新完成后,用新 token 重发 A;
- 新的 A 请求再次进入拦截器;
- 此时又是一个 401?→ 继续刷新 → 无限循环!
因此,我们在第一次捕获时打上标记:
config._retry = true;
这样下次再遇到同一个请求就不会重复处理。
💡 注意:
_retry是自定义字段,不会被发送到后端,仅用于内部流程控制。
2. 队列中的回调函数是如何工作的?
重点看这一段:
requestsQueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(axios(config));
});
这里形成了一个典型的 Promise + 闭包 结构:
resolve来自当前请求的 Promise;config是当前请求的配置对象;- 当 future 某个时刻传入新 token 时,就能动态修改 header 并重新发起请求。
本质上,我们把“将来要用新 token 重试”的能力封装成了一个可延迟执行的函数。
3. 为什么要在 finally 中释放锁?
finally {
isRefreshing = false;
}
这是极其重要的一步。如果刷新过程中发生网络错误、服务端异常或代码抛错,try...catch 会中断执行流,跳过后续逻辑。
如果不放在 finally,就会造成:
isRefreshing一直为true- 后续所有请求都被卡在队列里
- 整个应用无法发起任何受保护接口请求
这就是典型的 资源泄漏 问题。务必保证锁的释放是原子且必然的。
五、边界情况与容错设计
再稳健的机制也要面对现实世界的不确定性。以下是几个必须考虑的边缘场景:
场景 1:refresh_token 也已过期
用户长时间未操作,连 refresh_token 都失效了。此时 /auth/refresh 请求也会返回 401。
我们应在 catch 块中统一处理:
useUserStore.getState().logout();
window.location.href = '/login';
并清除本地存储的所有凭证。
场景 2:多个标签页共享登录状态
用户在一个标签页登出,其他页面仍可能持有旧 token。虽然本次机制无法主动感知,但我们可以通过监听 localStorage 变化来辅助清理:
window.addEventListener('storage', (e) => {
if (e.key === 'auth' && e.newValue === null) {
// 检测到登出事件
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
}
});
前提是登出时写入 storage 事件。
场景 3:网络不稳定导致刷新失败
偶尔的网络抖动可能导致 refresh 请求失败。是否应该重试?
取决于业务敏感度:
- 普通系统:直接登出即可;
- 高可用要求:可在 catch 中加入指数退避重试(最多 2 次);
let retries = 0;
const maxRetries = 2;
async function tryRefresh() {
try {
// ...正常刷新
} catch (err) {
if (retries < maxRetries) {
retries++;
await sleep(500 * Math.pow(2, retries));
return tryRefresh();
} else {
logout();
}
}
}
但要注意不要过度重试,以免加重服务器负担。
六、优势总结:不只是“能用”
这套方案之所以能在生产环境中长期稳定运行,源于以下几个核心特性:
| 特性 | 说明 |
|---|---|
| ✅ 真正的无感体验 | 用户无需感知 token 刷新过程,操作连续 |
| ✅ 防并发刷新 | 通过布尔锁保证最多只有一个 refresh 请求 |
| ✅ 支持并发请求恢复 | 所有因 token 过期而失败的请求都能被正确重放 |
| ✅ 低耦合高内聚 | 完全封装在拦截器中,业务代码零感知 |
| ✅ 可维护性强 | 结构清晰,易于调试和扩展 |
更重要的是,它体现了一种 面向失败编程 的思维方式:我们不是在写“理想路径”,而是在设计“异常路径”的逃生通道。
七、潜在改进方向
没有完美的架构,只有持续演进的系统。以下是一些可以进一步优化的方向:
1. 使用 AbortController 实现请求取消
如果用户快速切换页面,部分已排队的请求可能已无意义。可通过注入 cancelToken 来支持取消机制。
2. 添加刷新日志上报
记录 refresh 成功率、耗时、失败原因等指标,有助于发现潜在问题(如频繁刷新可能是前端时间不同步导致)。
3. 支持多实例共存
在大型项目中,可能存在多个 axios 实例(例如文件上传专用实例),需确保拦截器逻辑能复用或继承。
八、结语:把异常当作常态来设计
JWT 的最大优点是“无状态”,但这也意味着一旦签发就难以收回。正因为如此,我们才更需要在客户端建立一套可靠的容错机制。
本文所展示的“无感刷新”方案,不是一个炫技式的黑科技,而是一种务实的工程实践。它不追求极致性能,也不依赖复杂框架,而是用最朴素的锁和队列,解决了最真实的问题。
当你下次面对“token 过期怎么办”这个问题时,希望你能想到:
“我不是要阻止错误发生,而是要让它发生得悄无声息。”
这才是现代前端应有的成熟姿态。
📌 适用建议
- ✅ 单页应用(SPA)、管理系统、内容平台
- ✅ 使用 JWT + Refresh Token 的认证体系
- ✅ 对用户体验要求较高的产品
- ❌ 不适用于基于 Session-Cookie 的传统架构
如果你也在构建类似的鉴权系统,欢迎留言交流你的实现方式。共勉!