前端如何检测和处理过期的 Token?
前端检测和处理过期 Token 是保障用户体验和应用安全的关键环节。通常有被动处理(响应拦截)和主动处理(请求前检查)两种主要策略,实际项目中通常结合使用。
以下是详细的解决方案和代码示例(以 Axios 为例):
1. 核心概念:双 Token 机制
在讨论处理之前,通常预设后端采用了 双 Token 机制:
- Access Token: 短期有效(如 15 分钟),用于请求资源。
- Refresh Token: 长期有效(如 7 天),用于换取新的 Access Token。
2. 策略一:被动处理(响应拦截器)
这是最常用的方式。前端正常发送请求,当后端返回 401 Unauthorized 时,前端拦截该错误,尝试刷新 Token 并重发原请求。
难点处理:并发请求
当页面同时发出 5 个请求且 Token 过期时,会瞬间收到 5 个 401。如果处理不当,会触发 5 次刷新 Token 接口,导致资源浪费或逻辑错误。必须引入“请求锁”和“重试队列”。
代码实现(Axios 拦截器):
javascript
import axios from 'axios';
// 是否正在刷新 Token 的标记
let isRefreshing = false;
// 重试队列,每一项是一个函数
let requestsQueue = [];
const instance = axios.create({
baseURL: '/api',
});
// 响应拦截器
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
// 如果没有响应(网络错误等),直接抛出
if (!response) return Promise.reject(error);
// 检测是否是 Token 过期 (通常状态码为 401)
// 注意:有些后端可能返回 200 但在 body 里标记 code: 401,需根据实际调整
if (response.status === 401 && !config._retry) {
// 如果正在刷新,将当前请求放入队列
if (isRefreshing) {
return new Promise((resolve) => {
requestsQueue.push((newToken) => {
config.headers['Authorization'] = `Bearer ${newToken}`;
resolve(instance(config)); // 重新发送请求
});
});
}
// 开启刷新锁
config._retry = true; // 标记该请求已重试过,防止死循环
isRefreshing = true;
try {
// 1. 调用刷新 Token 接口 (携带 Refresh Token)
const { data } = await axios.post('/auth/refresh-token', {
refreshToken: localStorage.getItem('refreshToken'),
});
const newAccessToken = data.accessToken;
// 2. 保存新 Token
localStorage.setItem('accessToken', newAccessToken);
// 3. 设置当前失败请求的 Header
config.headers['Authorization'] = `Bearer ${newAccessToken}`;
// 4. 执行队列中的请求
requestsQueue.forEach((cb) => cb(newAccessToken));
requestsQueue = []; // 清空队列
// 5. 重发当前请求
return instance(config);
} catch (refreshError) {
// 刷新失败(Refresh Token 也过期了,或者被篡改)
// 执行登出逻辑
handleLogout();
return Promise.reject(refreshError);
} finally {
// 释放锁
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
function handleLogout() {
localStorage.clear();
window.location.href = '/login';
}
3. 策略二:主动处理(请求前检查)
在发送请求之前,先解码 Token(JWT),判断 exp(过期时间)是否已到。如果快过期了,先刷新再发请求。
优点:减少了一次必然失败的 HTTP 请求,响应更快。
缺点:依赖客户端时间(虽然可以留 buffer),且需要引入解码库。
代码实现:
需要安装 jwt-decode:npm install jwt-decode
javascript
import jwt_decode from "jwt-decode";
import axios from "axios";
// 检查 Token 是否过期
const isTokenExpired = (token) => {
try {
const decoded = jwt_decode(token);
const currentTime = Date.now() / 1000;
// 预留 10 秒缓冲时间,避免请求在网络传输中过期
return decoded.exp < currentTime + 10;
} catch (e) {
return true;
}
};
// 请求拦截器
instance.interceptors.request.use(async (config) => {
let token = localStorage.getItem('accessToken');
if (token && isTokenExpired(token)) {
// Token 过期,尝试刷新
try {
const { data } = await axios.post('/auth/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
});
token = data.accessToken;
localStorage.setItem('accessToken', token);
} catch (e) {
handleLogout();
return Promise.reject(e);
}
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
4. 策略三:定时器自动刷新(静默刷新)
在用户登录成功或页面加载时,解析 Token 的过期时间,设置一个 setTimeout,在过期前自动请求刷新接口。
- 适用场景:需要极高实时性的应用(如股票交易、即时通讯),不希望用户操作时有任何因为刷新 Token 带来的延迟。
- 缺点:如果用户打开页面后挂机,会一直消耗服务器资源刷新 Token(可以通过监听鼠标/键盘事件来优化,长时间无操作不刷新)。
5. 总结与最佳实践
在实际的大型项目中,通常采用 组合拳:
- 首选被动处理(拦截器):这是兜底方案,必须要有。因为它能处理所有意外情况(如管理员手动封禁了 Token,虽然没过期但失效了)。
- 可选主动处理:为了极致体验,可以在路由跳转前或关键操作前进行主动检查。
- 处理并发:务必在拦截器中实现
isRefreshing锁和requestsQueue队列,否则后端日志会报错,前端也会卡顿。 - 彻底失败后的处理:如果 Refresh Token 也过期了(或者接口报错),必须执行
logout清理本地存储并重定向到登录页。
安全提示:
- 尽量不要把 Token 放在
localStorage,容易被 XSS 攻击窃取。 - 更安全的做法是:Access Token 放在内存(变量)中,Refresh Token 放在
HttpOnly Cookie中。这样前端 JS 读不到 Refresh Token,攻击者无法伪造刷新请求。