多标签页JWT刷新踩坑
单页应用(SPA)里最容易被忽略的坑就是:用户习惯开很多个标签页。如果你在Axios拦截器里写了
这种处理方式比之前折腾的内存队列硬核得多,也稳得多。建议所有做大模型 AI Agent 工作流前端的同学都检查一下自己的 Auth 逻辑,别让用户在多窗口操作时莫名其妙掉线。
下一篇
Voice AI 支付集成:用 Ringup 实现通话中快捷结账 →
isRefreshing这种内存锁,在单个页面里确实能跑通,但一旦用户开了4个Tab,Token过期那一刻,这4个页面会同时触发刷新请求。如果后端开启了“刷新令牌轮转(Refresh Token Rotation)”,也就是用一次新Token就会让旧的失效,那结果就是:Tab 1刷新成功,Tab 2紧随其后刷新,直接把Tab 1刚拿到的Token给废了。最后用户在所有页面被强制踢回登录页,极其崩溃。
核心问题就在于:JavaScript的内存空间是隔离的。 Tab A里的isRefreshing = true,Tab B根本看不见。
要解决这种跨标签页的并发冲突,得用浏览器原生的 Web Locks API (navigator.locks),它能让同源的不同标签页竞争同一个锁。
实操方案如下:
一、引入 Web Locks 逻辑
不再依赖简单的布尔值,而是请求一个名为 auth_refresh_lock 的排他锁。
async function refreshToken() {
// 请求一个跨标签页的独占锁
return await navigator.locks.request('auth_refresh_lock', async () => {
// 1. 先检查 localStorage,看是不是已经有其他标签页刷新好了
const latestToken = localStorage.getItem('access_token');
if (isTokenValid(latestToken)) {
return latestToken;
}
// 2. 真正发起网络请求
try {
const response = await axios.post('/auth/refresh-token/', {
refresh_token: localStorage.getItem('refresh_token')
});
const { accessToken } = response.data;
// 3. 存入存储,让其他等待锁的标签页能直接拿到
localStorage.setItem('access_token', accessToken);
return accessToken;
} catch (error) {
throw error;
}
});
}二、集成到 Axios 拦截器
在响应拦截器里,当捕获到 401 错误时,调用上述带锁的刷新函数。
axios.interceptors.response.use(
res => res,
async error => {
if (error.response?.status === 401) {
try {
// 这里会排队,只有拿到锁的 Tab 才会发请求
const newToken = await refreshToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
return axios(error.config);
} catch (refreshError) {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);这个方案的精髓在于:
- 互斥执行: 无论开了多少个Tab,同一时间只有一个在请求
/refresh-token/。 - 结果共享: 后到者拿到锁后,先检查
localStorage,发现 Token 已经更新了,直接拿来用,无需再次请求。
这种处理方式比之前折腾的内存队列硬核得多,也稳得多。建议所有做大模型 AI Agent 工作流前端的同学都检查一下自己的 Auth 逻辑,别让用户在多窗口操作时莫名其妙掉线。