axios 封装那点破事:401 无感刷新踩过的坑
摘要 :本文详细分享了在 React + axios 项目中封装 HTTP 请求层时遇到的三大核心挑战及解决方案:1) 双存储策略 ------通过 localStorage 与 sessionStorage 的优先级读取与一致性回写,实现"记住我"功能;2) 401 无感刷新 ------利用锁(isRefreshing)与队列(failedQueue)机制解决并发刷新问题,确保多个过期请求只刷新一次 token;3) 跨模块登出协同------通过全局事件广播解耦 HTTP 层与 React 状态层。此外还介绍了前端预判 token 过期、设备指纹生成等实用技巧。整套方案经过多轮迭代,有效提升了项目的稳定性与开发体验。
前端这块业务代码写多了你会发现,真正考验功底的往往不是那些花哨的框架特性,而是几个"基础设施"层------HTTP 请求封装就是其中之一。写得糙,整个项目到处是重复的错误处理;写得好,业务层只管调接口拿数据。
我们项目用的是 React + axios,封装层前前后后改了好几版,踩的坑够写一篇了。主要讲三块:双存储策略、401 无感刷新、跨模块的登出协同。
token 到底存哪------一个比想象中烦的问题
最开始的版本很天真,登录成功就把 token 塞 localStorage:
typescript
localStorage.setItem('accessToken', response.data.accessToken);
直到有一天产品提了个需求:"加个'记住我'勾选框,不勾的话关浏览器就要重新登录。"
这就麻烦了。不勾"记住我",token 得存 sessionStorage(关标签页就没了);勾了,存 localStorage(持久化)。那读的时候到底从哪读?
最后搞了个双存储策略------优先读 localStorage,没有再读 sessionStorage:
typescript
// request.ts 请求拦截器
request.interceptors.request.use((config) => {
// 先 localStorage(记住我),再 sessionStorage(不记住)
const token = localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// ...
return config;
});
刷新 token 的时候也得注意------原来存哪,刷新后还存哪,不能乱:
typescript
// 拿到新 token 后,按原始位置回写
if (localStorage.getItem('accessToken')) {
localStorage.setItem('accessToken', accessToken);
} else {
sessionStorage.setItem('accessToken', accessToken);
}
这块逻辑在好几个地方重复(初始化、刷新、登录),后来抽了个工具函数统一处理。这种小细节看着不起眼,但漏一处就会出 bug------比如刷新时存错地方,用户明明没勾"记住我",刷新 token 却进了 localStorage,关浏览器也不掉了,体验就错了。
401 无感刷新:真正的难点
光存取 token 还不算难。真正搞死人的是 access token 过期后的无感刷新。
背景是这样的:access token 有效期短(比如 2 小时),refresh token 有效期长(7 天)。access token 过期了,用 refresh token 去换一对新的,用户全程无感。
听起来简单,但有个要命的并发问题:
用户打开页面,同时发了 5 个请求。这时候 access token 刚好过期了,5 个请求全部返回 401。如果每个 401 都各自去刷新 token,你会:
- 同时发 5 个 refresh 请求(后端可能限制并发刷新)
- 后刷新的拿到新 token,把先刷新的覆盖了
- 请求队列混乱,部分请求用旧 token 重试又 401
正确做法是只刷新一次,其他请求排队等。这是这套机制的核心。
用一个锁 + 一个队列搞定
typescript
let isRefreshing = false; // 锁:当前是否正在刷新
let failedQueue: Array<{ resolve: (value?: any) => void; reject: (reason?: any) => void }> = []; // 等待队列
const processQueue = (error: AxiosError | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error); // 刷新失败,全部拒绝
} else {
prom.resolve(token); // 刷新成功,把新 token 发给等待的请求
}
});
failedQueue = [];
};
然后是 401 的处理逻辑。这里要分两种情况:第一个撞上 401 的请求 和后续撞上 401 的请求,处理方式完全不同:
typescript
request.interceptors.response.use(
(response) => { /* 正常响应处理 */ },
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// 401 且没重试过
if (error.response?.status === 401 && !originalRequest._retry) {
// 情况一:已经在刷新了 → 把自己塞进队列等
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
// 刷新完了,拿到新 token,重发自己的请求
originalRequest.headers.Authorization = `Bearer ${token}`;
return request(originalRequest);
})
.catch((err) => Promise.reject(err));
}
// 情况二:我是第一个撞 401 的 → 由我来负责刷新
originalRequest._retry = true; // 标记"正在重试",防止死循环
isRefreshing = true; // 上锁
const refreshToken = localStorage.getItem('refreshToken')
|| sessionStorage.getItem('refreshToken');
if (!refreshToken) {
clearAuth();
window.location.href = '/login';
return Promise.reject(error);
}
try {
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh`, { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
// 按原始存储位置回写(前面说的双存储)
if (localStorage.getItem('accessToken')) {
localStorage.setItem('accessToken', accessToken);
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken);
} else {
sessionStorage.setItem('accessToken', accessToken);
if (newRefreshToken) sessionStorage.setItem('refreshToken', newRefreshToken);
}
// 放行队列里所有等待的请求
processQueue(null, accessToken);
// 重发自己这个请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return request(originalRequest);
} catch (refreshError) {
// 刷新失败(refresh token 也过期了)→ 队列全部拒绝,踢去登录
processQueue(error, null);
clearAuth();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 解锁
}
}
return Promise.reject(error);
}
);
这块代码看着长,逻辑其实是清晰的:
isRefreshing锁:保证全局只有一个 refresh 请求在飞。failedQueue队列:后续撞 401 的请求不自己刷新,而是 Promise 挂起塞进队列,等第一个刷新完。_retry标记:防止刷新请求本身又 401,形成死循环。刷新如果失败,直接判定 refresh token 也过期,踢登录。processQueue放行:刷新成功后统一把新 token 发给队列里的所有请求,让它们各自重发。
画个图就是:
ini
请求1 ─┐
请求2 ─┼─→ 全部 401
请求3 ─┘
请求1 拿到锁,去 refresh
请求2、请求3 发现 isRefreshing=true → 进队列等
refresh 成功,拿到新 token
↓
processQueue(token) → 请求2、请求3 拿到新 token,各自重发
请求1 自己也用新 token 重发
请求1、2、3 全部成功,用户全程无感
_retry 这个标记别漏
有个坑专门说一下。originalRequest._retry = true 这行看着不起眼,漏了会出大事。
假设没有这个标记:刷新失败了(refresh token 也过期),这时候走 clearAuth() + 跳登录页。但是------刷新请求本身也是用 axios 发的,如果它返回的也是 401(很可能,因为 refresh 接口也可能用过期 token),又会触发这个拦截器,又去刷新......死循环。
加了 _retry 后,标记过的请求即使再 401,也不会进刷新分支(!originalRequest._retry 这个条件拦住了),直接 reject。这个细节我在 Stack Overflow 上翻了好几个答案才拼出来。
登出协同:HTTP 层和 React 状态层的解耦
还有一个问题是:HTTP 层发现该登出了(403、refresh 失败),怎么通知 React 把用户状态清掉?
最初我在 request.ts 里直接 window.location.href = '/login',能用,但粗暴------整页刷新,用户体验差。而且 React 的 user 状态还在内存里,没被清干净。
后来改成事件广播的方式。request.ts 不直接操作 React,而是发一个全局事件:
typescript
function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('userInfo');
sessionStorage.removeItem('accessToken');
sessionStorage.removeItem('refreshToken');
sessionStorage.removeItem('userInfo');
// 关键:发个事件出去,谁监听谁处理
window.dispatchEvent(new Event('auth:logout'));
}
React 侧的 AuthContext 监听这个事件:
typescript
useEffect(() => {
const handleForceLogout = () => {
dispatch({ type: 'LOGOUT' }); // 清 React 状态
};
window.addEventListener('auth:logout', handleForceLogout);
return () => window.removeEventListener('auth:logout', handleForceLogout);
}, []);
这样 HTTP 层只管"发现该登出了 → 清存储 → 广播事件",至于登出后 UI 怎么变(跳转、清状态、显示提示),交给 React 自己处理。职责分明,好维护。
顺手加的:前端预判 token 过期
后端返回 401 再刷新,毕竟还是多了一次失败请求。能不能前端自己判断 token 要过期了,主动刷新?
JWT 是可以前端解析的(payload 是 base64),里面有 exp 过期时间戳。写个函数判断:
typescript
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp) {
return Date.now() >= payload.exp * 1000;
}
return false;
} catch {
return true; // 解析失败按过期处理
}
}
初始化时用这个函数判断,避免拿着过期 token 还去发请求:
typescript
useEffect(() => {
let accessToken = localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken') || sessionStorage.getItem('refreshToken');
if (accessToken && refreshToken) {
if (isTokenExpired(accessToken)) {
// access token 过期了
if (isTokenExpired(refreshToken)) {
// refresh 也过期了,彻底登出
clearAuth();
dispatch({ type: 'SET_LOADING', payload: false });
return;
}
// access 过期但 refresh 没过期:正常进入,后续请求会触发无感刷新
}
dispatch({ type: 'INIT_AUTH', payload: { user, accessToken, refreshToken } });
}
}, []);
这个预判主要是提升首屏体验------别一上来就发一堆必失败的请求,然后才触发刷新。提前判断好,体验更顺滑。
设备指纹:一个小彩蛋
请求头里还带了个 X-Device-Id,用来标识设备。这玩意儿是给后端做风控用的------同一个账号在不同设备登录,后端能识别出来。
生成方式是 base64 编码一个平台+时间戳+随机数的组合:
typescript
function generateDeviceId(): string {
const ua = navigator.userAgent;
const platform = navigator.platform;
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
return btoa(`${platform}-${timestamp}-${random}`).replace(/[^a-zA-Z0-9]/g, '').substring(0, 32);
}
// 请求拦截器里
let deviceId = localStorage.getItem('deviceId');
if (!deviceId) {
deviceId = generateDeviceId();
localStorage.setItem('deviceId', deviceId);
}
config.headers['X-Device-Id'] = deviceId;
第一次生成存 localStorage,之后就固定了。不算严格意义的设备指纹(只是个随机 ID),但够用,也不涉及隐私问题。
最后说两句
axios 封装这个东西,看着是个小模块,但牵扯的点特别多:存储策略、并发控制、状态协同、安全风控。每一块单独看都不难,但要把它们揉到一起还不互相打架,得仔细想。
我们这套封装也是迭代了好几版才稳定下来的。最早 401 刷新没做并发控制,偶尔会有 token 被覆盖的 bug;后来加了锁和队列才彻底解决。所以说基础设施层真的值得花时间打磨,它省的是整个项目所有业务页面的重复劳动。