一、方案概述
无感刷新Token的核心原理是在用户Token过期前,自动使用Refresh Token获取新的Access Token,确保用户操作不中断。本方案包含完整的Token管理、请求拦截、刷新机制和错误处理。
二、完整代码实现
1. Token管理类 (tokenManager.js)
/**
* Token管理器
* 负责Token的存储、获取和刷新
*/
class TokenManager {
constructor() {
this.accessTokenKey = 'access_token';
this.refreshTokenKey = 'refresh_token';
this.tokenExpiryKey = 'token_expiry';
}
/**
* 设置Token
* @param {Object} tokenData - Token数据
*/
setTokens(tokenData) {
const { access_token, refresh_token, expires_in } = tokenData;
// 计算Token过期时间(提前5分钟刷新)
const expiryTime = Date.now() + (expires_in - 300) * 1000;
localStorage.setItem(this.accessTokenKey, access_token);
localStorage.setItem(this.refreshTokenKey, refresh_token);
localStorage.setItem(this.tokenExpiryKey, expiryTime.toString());
}
/**
* 获取Access Token
* @returns {string|null}
*/
getAccessToken() {
return localStorage.getItem(this.accessTokenKey);
}
/**
* 获取Refresh Token
* @returns {string|null}
*/
getRefreshToken() {
return localStorage.getItem(this.refreshTokenKey);
}
/**
* 清除所有Token
*/
clearTokens() {
localStorage.removeItem(this.accessTokenKey);
localStorage.removeItem(this.refreshTokenKey);
localStorage.removeItem(this.tokenExpiryKey);
}
/**
* 检查Token是否即将过期
* @returns {boolean}
*/
isTokenExpiring() {
const expiryTime = localStorage.getItem(this.tokenExpiryKey);
if (!expiryTime) return true;
return Date.now() >= parseInt(expiryTime);
}
/**
* 检查是否已登录
* @returns {boolean}
*/
isLoggedIn() {
return !!this.getAccessToken() && !!this.getRefreshToken();
}
}
export const tokenManager = new TokenManager();
2. 请求拦截器封装 (httpClient.js)
import axios from 'axios';
import { tokenManager } from './tokenManager';
import { refreshToken } from './authService';
/**
* 创建axios实例
*/
const httpClient = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求队列,用于处理刷新Token时的请求排队
let isRefreshing = false;
let failedQueue = [];
/**
* 处理队列中的失败请求
* @param {string} token - 新的Access Token
*/
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
/**
* 请求拦截器
*/
httpClient.interceptors.request.use(
async (config) => {
const token = tokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 如果Token即将过期,且不是刷新Token的请求,尝试刷新
if (tokenManager.isTokenExpiring() &&
!config.url.includes('/refresh-token') &&
!config._retry) {
if (!isRefreshing) {
isRefreshing = true;
config._retry = true;
try {
const newToken = await refreshToken();
tokenManager.setTokens(newToken);
// 更新当前请求的Authorization头
config.headers.Authorization = `Bearer ${newToken.access_token}`;
// 处理等待队列
processQueue(null, newToken.access_token);
isRefreshing = false;
} catch (error) {
// 刷新失败,清除Token并重定向到登录页
processQueue(error, null);
tokenManager.clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
} else {
// 如果正在刷新,将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
config.headers.Authorization = `Bearer ${token}`;
return config;
}).catch(err => {
return Promise.reject(err);
});
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* 响应拦截器
*/
httpClient.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// 处理401错误(Token过期)
if (error.response?.status === 401 && !originalRequest._retry) {
// 如果是刷新Token接口返回401,直接跳转登录
if (originalRequest.url.includes('/refresh-token')) {
tokenManager.clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
originalRequest._retry = true;
// 如果已经在刷新,将请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return httpClient(originalRequest);
}).catch(err => {
return Promise.reject(err);
});
}
isRefreshing = true;
try {
const newToken = await refreshToken();
tokenManager.setTokens(newToken);
// 更新当前请求的Authorization头
originalRequest.headers.Authorization = `Bearer ${newToken.access_token}`;
// 处理等待队列
processQueue(null, newToken.access_token);
// 重新发起原始请求
return httpClient(originalRequest);
} catch (refreshError) {
// 刷新失败,清除Token并跳转登录
processQueue(refreshError, null);
tokenManager.clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default httpClient;
3. 认证服务 (authService.js)
import httpClient from './httpClient';
import { tokenManager } from './tokenManager';
/**
* 认证服务
*/
class AuthService {
/**
* 用户登录
* @param {string} username - 用户名
* @param {string} password - 密码
* @returns {Promise}
*/
async login(username, password) {
try {
const response = await httpClient.post('/auth/login', {
username,
password,
});
const tokenData = response.data;
tokenManager.setTokens(tokenData);
return tokenData;
} catch (error) {
throw this.handleAuthError(error);
}
}
/**
* 刷新Token
* @returns {Promise}
*/
async refreshToken() {
const refreshToken = tokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await httpClient.post('/auth/refresh-token', {
refresh_token: refreshToken,
}, {
_retry: true, // 标记为刷新Token请求
});
return response.data;
} catch (error) {
throw this.handleAuthError(error);
}
}
/**
* 用户注销
*/
logout() {
// 调用后端注销接口(可选)
httpClient.post('/auth/logout').catch(() => {});
// 清除本地Token
tokenManager.clearTokens();
// 跳转到登录页
window.location.href = '/login';
}
/**
* 获取当前用户信息
* @returns {Promise}
*/
async getCurrentUser() {
try {
const response = await httpClient.get('/auth/me');
return response.data;
} catch (error) {
throw this.handleAuthError(error);
}
}
/**
* 处理认证错误
* @param {Error} error - 错误对象
* @returns {Error}
*/
handleAuthError(error) {
if (error.response?.status === 401) {
tokenManager.clearTokens();
window.location.href = '/login';
}
return error;
}
}
export const authService = new AuthService();
export const refreshToken = () => authService.refreshToken();
4. React Hook 使用示例 (useAuth.js 其他框架原理相同)
import { useState, useEffect, useCallback } from 'react';
import { authService, tokenManager } from './authService';
/**
* 认证Hook
*/
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/**
* 登录
*/
const login = useCallback(async (username, password) => {
try {
setLoading(true);
setError(null);
await authService.login(username, password);
await loadUser();
} catch (err) {
setError(err.message || '登录失败');
throw err;
} finally {
setLoading(false);
}
}, []);
/**
* 注销
*/
const logout = useCallback(() => {
authService.logout();
setUser(null);
}, []);
/**
* 加载用户信息
*/
const loadUser = useCallback(async () => {
if (!tokenManager.isLoggedIn()) {
setLoading(false);
return;
}
try {
const userData = await authService.getCurrentUser();
setUser(userData);
setError(null);
} catch (err) {
setError(err.message);
setUser(null);
} finally {
setLoading(false);
}
}, []);
// 初始化加载用户信息
useEffect(() => {
loadUser();
}, [loadUser]);
return {
user,
loading,
error,
login,
logout,
isAuthenticated: !!user,
reloadUser: loadUser,
};
}
5. 全局配置和初始化 (app.js 其他框架原理相同)
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { tokenManager } from './utils/tokenManager';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
/**
* 保护路由组件
*/
function PrivateRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div className="loading">加载中...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" />;
}
/**
* 应用主组件
*/
function App() {
// 监听页面可见性变化,当页面从隐藏变为可见时检查Token
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden && tokenManager.isLoggedIn()) {
// 页面变为可见时,检查Token是否需要刷新
if (tokenManager.isTokenExpiring()) {
// 触发Token刷新
authService.refreshToken().catch(() => {
// 刷新失败可忽略,下次请求时会处理
});
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard/*"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;
6. API接口使用示例 (userService.js)
import httpClient from './httpClient';
/**
* 用户服务示例
*/
class UserService {
/**
* 获取用户列表
*/
async getUsers(params = {}) {
try {
const response = await httpClient.get('/users', { params });
return response.data;
} catch (error) {
this.handleRequestError(error);
}
}
/**
* 更新用户信息
*/
async updateUser(userId, data) {
try {
const response = await httpClient.put(`/users/${userId}`, data);
return response.data;
} catch (error) {
this.handleRequestError(error);
}
}
/**
* 处理请求错误
*/
handleRequestError(error) {
// 这里可以添加自定义的错误处理逻辑
if (error.response?.status === 403) {
// 权限不足
throw new Error('权限不足,请联系管理员');
}
throw error;
}
}
export const userService = new UserService();
三、说明
-
请求队列机制 当Token正在刷新时,新的请求会进入队列等待 Token刷新成功后,队列中的请求会使用新Token重新发起 防止并发刷新请求
-
提前刷新策略 Token过期前5分钟开始刷新 减少用户等待时间
-
页面可见性检测 当页面从后台切回前台时自动检查Token状态 保持Token始终有效
-
错误处理 401错误自动处理 刷新失败自动跳转登录页 提供友好的错误提示
-
安全性考虑 Refresh Token存储在安全的地方 支持服务端注销Refresh Token 防止Token泄露
四、注意事项
-
存储安全:生产环境中建议考虑更安全的存储方式,如httpOnly Cookie
-
并发控制:确保同一时间只有一个刷新请求
-
错误降级:网络异常时的降级处理
-
监控告警:Token刷新失败的监控和告警
-
跨域支持:确保后端支持CORS配置