前端面试:无感刷新Token的深度解析与异常处理
背景回顾
在最近一次前端面试中,面试官提出问题:"如何实现无感刷新Token?” 我回答后进一步提问:“如果Refresh Token失败报错,同时在A页面有10个业务接口也报错了,例如同时10个异常提示,你会怎么处理?"。异常处理没有回答好(当时考虑-拦截器+状态码,实际上异常提示有间隔时间怎么会同时报错呢-短时间触发只会提示一次?而且应该都是401状态码加路由守卫 是能处理好的)。现重新梳理一下,这个问题考察了Token刷新的实现流程,以及并发请求的异常处理。
原理和实践
一、无感刷新Token的实现原理
无感刷新的核心在于双Token机制:通过短时效的Access Token(AT)和长时效的Refresh Token(RT)配合,实现用户无感知的凭证更新。其实现流程分为以下步骤:
-
登录阶段
- 用户输入账号密码后,前端发送登录请求至
/login接口。 - 后端验证成功后,返回Access Token(例如有效期2h)和Refresh Token(有效期7天)。
- 前端将Token存储在
localStorage。
- 用户输入账号密码后,前端发送登录请求至
-
请求拦截阶段
- 使用Axios拦截器自动为所有请求添加
Authorization: Bearer ${accessToken}头。 - 示例代码:
axios.interceptors.request.use(config => { const token = localStorage.getItem('accessToken'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; });
- 使用Axios拦截器自动为所有请求添加
-
响应拦截阶段
- 当后端返回
401 Unauthorized时,触发Token刷新流程。 - 关键逻辑:
- 检查是否存在
isRefreshing标记,避免重复刷新。 - 将后续请求存入队列,等待新Token返回后重试。
- 检查是否存在
- 当后端返回
-
Token刷新阶段
- 发送POST请求至
/refresh接口,携带refreshToken。 - 成功时更新本地Token并重试队列请求;失败时清除Token并跳转登录页。
- 发送POST请求至
二、并发请求异常处理方案
当Refresh Token失败且存在多个并发请求时,需通过请求队列+状态管理实现优雅降级:
-
问题场景还原
- 用户Token过期时,A页面同时发起10个接口请求,均返回
401。 - 若未处理并发,会导致10次重复刷新请求,增加服务器压力。
- 用户Token过期时,A页面同时发起10个接口请求,均返回
-
解决方案步骤
- 步骤1:引入请求队列管理器
使用类管理刷新状态和待重试请求:
- 步骤1:引入请求队列管理器
// 刷新Token管理器
class TokenRefreshManager {
constructor() {
this.subscribers = []; // 订阅者队列:存储等待Token刷新的请求回调
this.isRefreshing = false; // 是否正在刷新Token
this.refreshPromise = null; // 存储当前刷新Token的Promise,用于避免重复刷新
}
// 订阅Token刷新事件
subscribe(callback) {
this.subscribers.push(callback);
}
// 当Refresh Token失败时,统一处理所有错误
onRefreshFailed(error) {
this.subscribers.forEach(cb => cb(null, error)); // 通知所有订阅者刷新失败
this.subscribers = []; // 清空队列
this.isRefreshing = false;
this.refreshPromise = null;
// 1. 清除本地无效的Token
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// 2. 显示一个统一且友好的错误提示(而非10个)
this.showFriendlyError(error);
// 3. 跳转到登录页
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000); // 延迟2秒跳转,让用户看到提示信息
}
// 显示友好错误提示(使用Element UI的Message组件)
showFriendlyError(error) {
let errorMessage = '网络或服务异常,请重新登录。2秒后自动跳转...';
// 根据错误类型显示不同的友好信息
if (error?.response?.status === 403) {
errorMessage = '登录已过期,请重新登录。2秒后自动跳转...';
} else if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
errorMessage = '网络请求超时,请检查网络连接后重新登录。2秒后自动跳转...';
} else if (!navigator.onLine) {
errorMessage = '网络连接已断开,请检查网络设置。2秒后自动跳转...';
}
// 使用Element UI的Message组件显示错误提示
Message({
message: errorMessage,
type: 'error',
duration: 2000, // 设置为2000毫秒,与跳转延迟一致
showClose: true, // 显示关闭按钮,允许用户手动关闭
customClass: 'global-error-message', // 可选,添加自定义样式类
onClose: () => {
// 消息关闭时的回调(可选)
}
});
}
// 当Token刷新成功后,通知所有订阅者
onRefreshed(token) {
this.subscribers.forEach((cb) => cb(token));
this.subscribers = [];// 清空队列
}
async refresh() {
// 如果正在刷新,返回同一个Promise,避免重复请求
if (this.isRefreshing) {
return this.refreshPromise;
}
this.isRefreshing = true;
try {
const rt = localStorage.getItem('refreshToken');
const res = await axios.post('/refresh', { rt });
const newToken = res.data.at;
this.onRefreshed(newToken);
return newToken;
} catch (error) {
// 统一处理刷新失败
this.onRefreshFailed(error);
throw error;
} finally {
this.isRefreshing = false;
}
}
}
// 全局单例
export const tokenManager = new TokenRefreshManager();
-
步骤2:修改响应拦截器逻辑
axios.interceptors.response.use( response => response, async error => { const { config, response } = error; if (error.response?.status === 401 && !config._retry) { config._retry = true; // 标记请求已处理 const newToken = await tokenManager.refresh(); config.headers.Authorization = `Bearer ${newToken}`; return axios(config); } return Promise.reject(error); } ); -
步骤3:流程图说明
graph TD A[用户发起请求] --> B{Token有效?} B -- 是 --> C[正常返回数据] B -- 否 --> D{存在刷新请求?} D -- 否 --> E[发起刷新请求] E --> F{刷新成功?} F -- 是 --> G[更新Token并重试队列] F -- 否 --> H[跳转登录页] D -- 是 --> I[加入请求队列] G --> I
总结
现在想来当时面试官提问的"同时10个异常提示"问题,本质考察的是并发请求的队列管理能力。我的原始理解有误——并发请求不会导致10次独立提示,而是通过统一队列机制合并处理。关键在于:所有401请求会被收集到同一个刷新流程中,刷新失败后仅触发一次错误提示。