前言
最近在项目中遇到一个线上问题:用户反馈"使用过程中突然被踢到登录页"。排查后发现,页面加载时同时发出了多个 API 请求,恰逢 Access Token 过期,导致刷新逻辑被重复触发,Refresh Token 被提前消耗。
这个问题并不罕见,但网上的方案大多存在隐患。今天就来系统梳理一下 Token 无感刷新的完整实现思路。
Token 刷新的三个常见陷阱
现代 Web 应用普遍采用 JWT 双 Token 机制:
- Access Token ------ 短期有效(15~30 分钟)
- Refresh Token ------ 长期有效(7 天左右)
理想状态:Access Token 过期后,前端自动刷新并重试原请求,用户全程无感知。但实际实现中容易踩三个坑。
陷阱 1:并发雪崩
页面加载时 10 个接口同时发出,Token 恰好过期,10 个请求全部返回 401。如果拦截器直接刷新:
typescript
// 每个请求都触发一次刷新
axios.interceptors.response.use(null, async (error) => {
if (error.response.status === 401) {
const newToken = await refreshToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
return axios(error.config);
}
});
10 个 401 → 10 次 refreshToken() → 后端收到 10 个刷新请求。
轻则后端拒绝重复刷新,重则 Refresh Token 被提前消耗,用户反而被踢下线。
陷阱 2:无限循环
如果 refreshToken() 本身也返回 401(Refresh Token 过期),拦截器会再次触发 → 刷新 → 又 401 → 再重试......浏览器直接卡死。
陷阱 3:到处复制粘贴
就算写对了,每个新项目都得重复那一套 isRefreshing + failedQueue + processQueue 的逻辑,容易漏细节。
正确方案:单例锁 + 请求队列 + 重试标记
核心思路可以概括为三个字:锁、排、重。
css
请求 A ──→ 401 ──→ 加锁,发起刷新 ──→ 成功 ──→ 重试 A ──→ 200 ✅
请求 B ──→ 401 ──→ 已加锁,加入队列 ──┘
请求 C ──→ 401 ──→ 已加锁,加入队列 ──┘
└──→ 拿到新 Token,重试 B、C ──→ 200 ✅
- 锁 (
isRefreshing):第一个 401 负责刷新,后续 401 全部排队 - 排 (
failedQueue):将等待中的请求存入队列,刷新完成后统一处理 - 重 (
_retry标记):已重试的请求不再进入刷新逻辑,防止死循环
完整实现
状态与队列定义
typescript
let isRefreshing = false; // 单例锁
const failedQueue: QueueItem[] = []; // 请求队列
只需要两个变量,一个锁一个队列。
队列处理函数
typescript
const processQueue = (error: any, token: string | null) => {
failedQueue.forEach(({ resolve, reject }) => {
error ? reject(error) : resolve(token!);
});
failedQueue.length = 0; // 清空队列
};
刷新成功时统一 resolve(token),失败时统一 reject(error),然后清空队列。
响应拦截器
这是核心部分:
typescript
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalConfig = error.config;
// 1. 非 401 或已重试过 → 直接拒绝
if (!shouldRefresh(status) || retryCount >= maxRetryCount) {
return Promise.reject(error);
}
// 2. 已经在刷新中 → 加入队列等待
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject, config: originalConfig });
}).then((token) => {
setAuthHeader(originalConfig, token);
return axios(originalConfig); // 拿到新 Token 后重试
});
}
// 3. 首次触发 → 加锁、刷新
originalConfig._retry = true;
isRefreshing = true;
try {
const response = await refreshApiCall();
const newToken = extractToken(response);
processQueue(null, newToken); // 通知队列中所有等待的请求
setAuthHeader(originalConfig, newToken);
return axios(originalConfig); // 重试当前请求
} catch (refreshError) {
processQueue(refreshError, null); // 刷新失败,通知所有排队请求
clearAuth();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 无论成败,释放锁
}
}
);
几个关键细节:
- 队列中的请求通过
Promise挂起 ------ 调用方拿到的是一个未 resolved 的 Promise,刷新成功时统一 resolve,调用方的.then()自然触发重试 finally释放锁 ------ 即使刷新异常也不会死锁_retry标记 ------ 重试后的请求不会再次进入刷新流程,切断死循环
封装成通用方案的思路
上面的代码和业务耦合在一起(硬编码了刷新接口、Token 提取方式、失败跳转等)。要封装成通用方案,需要把这些行为全部抽成参数。
参数设计
经过梳理,真正需要外部传入的只有以下几项:
typescript
interface TokenRefreshOptions {
/** 必填:刷新 Token 的 API 请求函数 */
refreshApiCall: () => Promise<AxiosResponse>;
/** 必填:从刷新响应中提取新的 Access Token */
extractToken: (response: AxiosResponse) => string;
/** 可选:自定义设置请求头,默认 Bearer */
setAuthHeader?: (config: AxiosRequestConfig, token: string) => void;
/** 可选:触发刷新的状态码,默认 [401] */
statusCodes?: number[];
/** 可选:最大重试次数,默认 1 */
maxRetryCount?: number;
/** 可选:刷新成功回调 */
onRefreshSuccess?: (token: string, response: AxiosResponse) => void;
/** 可选:刷新失败回调 */
onRefreshFailure?: (error: any) => void;
}
为什么这么设计?
refreshApiCall 为什么是函数而不是 URL 字符串?
因为刷新请求的发起方式千差万别:有的需要额外 Header,有的需要加签名,有的要走独立的 Axios 实例。给一个函数,调用方完全掌控怎么发请求。
extractToken 为什么是函数而不是字段名?
后端返回格式不统一:有的 res.data.token,有的 res.data.access_token,有的嵌套在 res.data.data.accessToken。直接给 response,怎么取由调用方决定。
onRefreshSuccess / onRefreshFailure 为什么是回调?
Token 存在哪、刷新失败跳哪个页面,这些和业务强绑定。通用的方案不应该替调用方做这些决定,而是在正确的时机回调出去。
最终 API
typescript
import { createTokenRefreshInterceptor } from 'token-refresh-lock';
import axios from 'axios';
const instance = axios.create({ baseURL: '/api' });
const controller = createTokenRefreshInterceptor(instance, {
refreshApiCall: () => instance.post('/auth/refresh'),
extractToken: (res) => res.data.accessToken,
onRefreshSuccess: (token) => {
sessionStorage.setItem('access_token', token);
},
onRefreshFailure: () => {
sessionStorage.removeItem('access_token');
window.location.href = '/login';
},
});
// 登出时可以手动卸载拦截器
controller.eject();
效果验证
用 Mock 数据模拟了三个典型场景:
场景 1:单个请求 Token 过期
bash
请求 /api/user → 401 → 自动刷新 → 重试 → 200 ✅
刷新调用次数:1
场景 2:5 个并发请求同时 401
请求 1~5 全部 401 → 只刷新 1 次 → 全部重试成功 ✅
刷新调用次数:1(不是 5)
这是单例锁最核心的价值 ------ 无论多少个请求同时 401,刷新接口永远只被调用一次。
场景 3:Refresh Token 也过期
bash
请求 /api/user → 401 → 尝试刷新 → 刷新也失败 → onRefreshFailure 被调用 ✅
正常时无感刷新,异常时优雅降级。这才是生产环境需要的行为。
在不同框架中的集成方式
Vue 3 + Pinia
typescript
// stores/auth.ts
import { defineStore } from 'pinia';
import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';
const api = axios.create({ baseURL: '/api' });
export const useAuthStore = defineStore('auth', () => {
const token = ref('');
createTokenRefreshInterceptor(api, {
refreshApiCall: () => api.post('/auth/refresh'),
extractToken: (res) => res.data.accessToken,
onRefreshSuccess: (newToken) => { token.value = newToken; },
onRefreshFailure: () => {
token.value = '';
router.push('/login');
},
});
return { token };
});
React
typescript
// lib/request.ts
import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';
const api = axios.create({ baseURL: '/api' });
createTokenRefreshInterceptor(api, {
refreshApiCall: () => api.post('/auth/refresh'),
extractToken: (res) => res.data.accessToken,
onRefreshSuccess: (token) => {
sessionStorage.setItem('access_token', token);
},
onRefreshFailure: () => {
sessionStorage.removeItem('access_token');
window.location.href = '/login';
},
});
export default api;
小程序(uni-app + axios 适配)
typescript
import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';
const api = axios.create({ baseURL: 'https://your-api.com' });
createTokenRefreshInterceptor(api, {
refreshApiCall: () => api.post('/auth/refresh'),
extractToken: (res) => res.data.accessToken,
onRefreshFailure: () => {
uni.clearStorageSync();
uni.reLaunch({ url: '/pages/login/index' });
},
});
安全注意事项
| Token 类型 | 推荐存储 | 不推荐 | 原因 |
|---|---|---|---|
| Access Token | 内存 / sessionStorage | localStorage | 避免持久化泄露 |
| Refresh Token | HttpOnly Cookie | 任何前端可读位置 | 防 XSS 窃取 |
本文的方案假设 Refresh Token 存在后端下发的 HttpOnly Cookie 中,前端通过调用 /auth/refresh 自动携带。前端永远不应该直接接触 Refresh Token。
切勿将任何 Token 存入 localStorage,这是 XSS 攻击的高价值目标。
总结
Token 无感刷新看似简单,实则暗藏并发雪崩、无限循环、安全存储三大陷阱。本文梳理了一套经过验证的方案:
- 单例锁 ------ 同一时间只允许一次刷新请求,杜绝并发雪崩
- 请求队列 ------ 利用 Promise 机制将等待中的请求挂起,刷新成功后批量重试
- 重试标记 ------ 防止重试请求再次进入刷新流程,切断死循环
这个方案不复杂,但需要每个细节都考虑到位。把核心逻辑理解清楚后,无论是手写还是基于现有封装,都能避免常见的那些坑。
希望这篇文章能帮到正在实现 Token 刷新方案的同学。如果有更好的思路,欢迎交流讨论。