前言
在前后端分离的项目中,为了安全,Token 通常会设置有效期。但如果 Token 过期时强制用户重新登录,会极大地破坏用户体验。如何做到在用户毫无察觉的情况下,自动完成 Token 的续期?本文将深度拆解 "双 Token 无感刷新" 的实现机制。
一、 为什么需要"无感刷新"?
举个简单例子,你正在某 App 编辑内容,中途切出几分钟,再切回来时,直接弹出登录页,提示"登录已过期,请重新登录",这种场景很容易让用户流失。
传统的单 Token 方案存在一个两难境地:
- 有效期过短:用户操作频繁,动不动就跳回登录页,用户体验极差。
- 有效期过长:Token 一旦被截获,风险极高。
解决方案:双 Token 机制
- access_token:访问令牌。有效期短(如 1 小时),每次接口请求都携带,降低泄露风险。
- refresh_token :刷新令牌。有效期长(如 7 天),仅用于
access_token过期时换取新令牌。
只要用户在 7 天内活跃过,系统就能通过 refresh_token 自动"续命",实现长效无感登录。
二、 核心流程设计
-
正常请求 :前端携带
access_token访问。 -
触发过期 :后端返回 401 Unauthorized。
-
判断逻辑:
- 如果是普通接口报 401:说明
access_token失效,尝试刷新。 - 如果是刷新接口 报 401:说明
refresh_token也失效了,强制重新登录。
- 如果是普通接口报 401:说明
-
无感替换 :前端自动调用刷新接口,获取新 Token 覆盖本地存储,并重新发起之前失败的请求。
三、 细节攻坚:如何处理并发请求?
痛点:如果页面同时发出了 5 个请求,而此时 Token 刚好过期,会导致这 5 个请求同时触发"刷新 Token"的操作,造成资源浪费甚至后端异常。
解决策略:
- 状态锁 (
refreshing) :记录当前是否正在刷新中。 - 任务队列 (
queue) :在刷新期间到达的请求,先暂存起来,不直接报错。 - 批量回放:等待 Token 刷新成功后,依次执行队列里的请求,实现"无感"衔接。
四、 代码实现 (Axios 拦截器)
以下是基于 Axios 的完整工程化实现:
TypeScript
import axios, { AxiosRequestConfig } from 'axios';
interface PendingTask {
config: AxiosRequestConfig;
resolve: Function;
}
let refreshing = false; // 状态锁:标志是否正在刷新 Token
let queue: PendingTask[] = []; // 请求队列:暂存 Token 刷新期间的请求
const axiosInstance = axios.create({
baseURL: '/api'
});
// 1. 请求拦截器:自动注入 Token
axiosInstance.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken && config.headers) {
config.headers.authorization = `Bearer ${accessToken}`;
}
return config;
});
// 2. 响应拦截器:处理 Token 过期
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const { data, config } = error.response;
// 情况 A:正在刷新 Token 中,将后续请求存入队列
if (refreshing) {
return new Promise((resolve) => {
queue.push({ config, resolve });
});
}
// 情况 B:access_token 过期 (状态码 401 且非刷新接口本身)
if (data.statusCode === 401 && !config.url.includes('/refresh')) {
refreshing = true;
try {
const res = await refreshToken();
refreshing = false;
if (res.status === 200) {
// 核心逻辑:Token 刷新成功,回放队列中的所有请求
queue.forEach(({ config, resolve }) => {
resolve(axiosInstance(config));
});
queue = []; // 清空队列
// 执行当前触发刷新的那个请求
return axiosInstance(config);
}
} catch (err) {
refreshing = false;
queue = [];
// 情况 C:refresh_token 也过期了,彻底清除登录态
localStorage.clear();
window.location.href = '/login';
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
/**
* 刷新 Token 的异步方法
*/
async function refreshToken() {
const res = await axios.get('/api/refresh', {
params: {
token: localStorage.getItem('refresh_token')
}
});
// 更新本地存储
localStorage.setItem('access_token', res.data.accessToken);
localStorage.setItem('refresh_token', res.data.refreshToken);
return res;
}
五、 注意事项
- 并发请求的 Promise 挂起 :在
refreshing为true时,返回一个不带resolve的new Promise是关键,它能让 Axios 请求处于pending状态。 - 错误捕获 :
refreshToken接口本身报错(如 500 或 401)必须妥善处理,直接引导至登录页。 - 安全性 :普通项目中可以使用
localStorage,但在更高要求的项目中,建议配合HttpOnly Cookie存储refresh_token以防 XSS 攻击。 - 接口重定向陷阱:确保刷新 Token 的接口不会再次进入 401 拦截死循环。