axios 封装那点破事:401 无感刷新踩过的坑

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,你会:

  1. 同时发 5 个 refresh 请求(后端可能限制并发刷新)
  2. 后刷新的拿到新 token,把先刷新的覆盖了
  3. 请求队列混乱,部分请求用旧 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);
  }
);

这块代码看着长,逻辑其实是清晰的:

  1. isRefreshing:保证全局只有一个 refresh 请求在飞。
  2. failedQueue 队列:后续撞 401 的请求不自己刷新,而是 Promise 挂起塞进队列,等第一个刷新完。
  3. _retry 标记:防止刷新请求本身又 401,形成死循环。刷新如果失败,直接判定 refresh token 也过期,踢登录。
  4. 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;后来加了锁和队列才彻底解决。所以说基础设施层真的值得花时间打磨,它省的是整个项目所有业务页面的重复劳动。

相关推荐
阿懂在掘金2 小时前
同一份弹窗我重构了三次:从 v-model 地狱到路由式调用,终于治好了模板臃肿
前端·vue.js·前端框架
xexpertS5 小时前
前端工程转型实践:从 Ember 迁移到 React,提升构建速度与研发效能
前端·react.js·前端框架
霹雳桃7 小时前
移动端 H5 折叠屏适配实战:为什么 max-width 没用,min(vw, px) 才是正解
前端·前端框架
小林ixn8 小时前
Next.js 全栈实战:从 SPA 到 SSR,SEO 友好到底怎么落地?
react.js·前端框架·next.js
满栀5859 小时前
vue3动态路由详细效果
前端·javascript·vue.js·typescript·前端框架
东方小月1 天前
从零开发一个 Coding Agent(九):实现 Agent 的工具调用闭环
人工智能·前端框架·node.js
console.log('npc')1 天前
React + Ant Design 通用企业数据统计模块实战
前端·react.js·前端框架
To_OC2 天前
写了 5 个表单 Demo 后,我终于彻底搞懂了 React 受控与非受控组件
前端·react.js·前端框架