现代 Web 应用开发中,身份验证是保障系统安全的核心环节,而 Token 认证因灵活性高、无状态等优势被广泛采用。
但 Access Token 过期后突然报错或强制跳转登录,会严重影响体验。
Token 无感刷新 能在用户毫无察觉的情况下完成令牌更新,让操作流程无缝衔接。
本文从前端视角出发,一步步拆解落地方案,后端部分仅做必要说明。
一、先搞懂:Token 无感刷新的核心逻辑
令牌 | 作用 | 有效期 | 权限 |
---|---|---|---|
Access Token | 接口通行证 | 短期(1-2 h) | 降低泄露风险 |
Refresh Token | 换票凭证 | 长期(7-30 d) | 仅用于刷新 |
4 步无感流程
- 请求带 Access Token
- 后端返回 401
- 前端用 Refresh Token 调
/refresh
换新令牌 - 重试原请求,用户 0 感知
二、前端落地:从存储到拦截,全链路实现
1. 令牌存储策略
方案 | 优点 | 缺点 |
---|---|---|
localStorage | 操作简单、可跨域 | 易受 XSS |
httpOnly Cookie | 防 XSS | 跨域复杂、需防 CSRF |
本文示例用 localStorage,封装如下:
// tokenStorage.js
export const tokenStorage = {
setTokens: (accessToken, refreshToken) => {
const expiryTime = Date.now() + 2 * 60 * 60 * 1000; // 2 h
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('tokenExpiry', expiryTime);
},
getAccessToken: () => localStorage.getItem('accessToken'),
getRefreshToken: () => localStorage.getItem('refreshToken'),
clearTokens: () => {
['accessToken', 'refreshToken', 'tokenExpiry']
.forEach(k => localStorage.removeItem(k));
},
shouldRefreshEarly: () => {
const t = Number(localStorage.getItem('tokenExpiry'));
return !isNaN(t) && (t - Date.now() < 10 * 60 * 1000);
}
};
2. Axios 拦截器——核心战场
// api.js
import axios from 'axios';
import { tokenStorage } from './tokenStorage';
const baseApi = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
let isRefreshing = false;
let requestQueue = [];
const refreshAccessToken = async () => {
try {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh-token`,
{ refreshToken }
);
tokenStorage.setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch (e) {
tokenStorage.clearTokens();
location.href = '/login?redirect=' + encodeURIComponent(location.pathname);
throw e;
}
};
/* 请求拦截:自动加头 + 提前刷新 */
baseApi.interceptors.request.use(async config => {
if (tokenStorage.shouldRefreshEarly() && !isRefreshing) {
refreshAccessToken().catch(console.error);
}
const token = tokenStorage.getAccessToken();
if (token && config.headers) config.headers.Authorization = `Bearer ${token}`;
return config;
});
/* 响应拦截:401 无感重试 */
baseApi.interceptors.response.use(
res => res,
async err => {
const original = err.config;
if (!err.response || err.response.status !== 401 || original._retry) return Promise.reject(err);
original._retry = true;
if (!isRefreshing) {
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
requestQueue.forEach(cb => cb(newToken));
requestQueue = [];
original.headers.Authorization = `Bearer ${newToken}`;
return baseApi(original);
} catch (e) {
return Promise.reject(e);
} finally {
isRefreshing = false;
}
} else {
return new Promise(resolve => {
requestQueue.push(token => {
original.headers.Authorization = `Bearer ${token}`;
resolve(baseApi(original));
});
});
}
}
);
export default baseApi;
3. 体验优化细节
- 页面切回提前刷新
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' &&
tokenStorage.shouldRefreshEarly()) {
refreshAccessToken().catch(console.error);
}
});
-
刷新接口绕开拦截器
直接用axios.post
而不用baseApi
,防止循环加头。 -
主动登出清理现场
export const logout = () => {
tokenStorage.clearTokens();
isRefreshing = false;
requestQueue = [];
location.href = '/login';
};
三、后端配合(3 句话带过)
- Access Token 过期返回 401
- 提供
/refresh
接口:验 Refresh Token → 返回新双 Token - Refresh Token 存 Redis,支持黑名单 & 滚动刷新
四、常见问题速查
问题 | 答案 |
---|---|
刷新接口为何也返回新 Refresh Token? | 滚动刷新,降低重放风险 |
localStorage 怕 XSS 怎么办? | 改 httpOnly Cookie + 内存存 Access Token |
开发环境 Token 太短? | 单独配置长有效期,避免干扰调试 |
五、结语
“把 401 当换票信号,而不是踢人信号”
30 行拦截器代码,就能让登录态“永不下线”。
根据业务平衡安全与体验,敏感操作再叠加二次校验即可。
落地过程中有坑,欢迎在评论区交流!