为什么我在团队里禁止使用 axios?

上上个月,我在我们前端团队的工程规范文档里加了一条规则:新项目禁止引入 axios,老项目在下一次重构周期内逐步移除。

这条规则刚发出去,内部群就炸了。

有人说:用了好几年了,挺稳的,换个锤子啊🔨

也有人发了个表情包,配文:你这不是在革我们的命吗😢?

我理解这些反应。axios 在国内前端圈子里几乎是请求库的代名词。很多人入行第一天就在用,它已经不是一个技术选型的问题了。

但当我拆开这个项目的 node_modules 看了看,然后再打开 MDN 看了看 2026 年的 fetch API,我确认了一件事:大多数项目里的 axios,已经从解决问题的工具变成了不敢删的历史遗留。

今天我就把决策过程摊开,给你们讲一讲👇。


axios 到底解决了什么?那些问题今天还存在吗?

先说公道话,axios 当年确实有它存在的理由。

2016 年前后,原生 fetch 刚落地,基本上属于能用但不好用的状态:不支持请求超时、不支持拦截器、不支持进度监控、不能自动解析 JSON、4xx 和 5xx 不会走 catch。那个年代你要发一个 HTTP 请求,fetch 写起来确实又臭又长。

但 2026 年了,浏览器的 fetch 已经不是当年那个半成品了:

你仔细看这张表。axios 最核心的卖点------拦截器、超时、取消------要么已经被原生 API 覆盖,要么用不到 10 行封装就能实现。 而 axios 做不到的事情------流式响应(SSE、大文件流)------反而是 2026 年越来越多的业务场景真正需要的能力。


axios 在我们项目里制造的 3 个真实问题

我不是因为原生党的洁癖才做这个决定的。

拦截器嵌套成了调试黑洞

我们项目里有 4 层拦截器:鉴权注入、日志埋点、错误统一处理、灰度标记。每一层都在 request 和 response 上各挂了一个拦截器,总共 8 个。

当一个请求出了问题,你去调试的时候会发现:请求从你的业务代码发出去之后,要经过 4 层拦截器的变形,才真正到达网络层。 响应回来之后,又要经过 4 层拦截器的加工,才到达你的 .then()。中间任何一层吞掉了错误、改了 header、或者忘了 return,你都极难定位🤷‍♂️。

CancelToken 已废弃,但代码库里到处都是

axios 的 CancelToken 在 v0.22.0 就被标记为 deprecated 了,官方推荐改用 AbortController。但我们项目里有几十处 CancelToken 的用法,没人敢动,因为改了怕出事,不改跑着也没问题 。结果就是新代码用 AbortController,老代码用 CancelToken,两套取消机制并存,维护成本直接翻倍 😖。

在 Edge Runtime 和 SSR 场景下踩坑

我们有一部分接口走 Cloudflare Workers。Workers 的运行时是 V8 Isolate,原生支持 fetch,但不支持 XMLHttpRequest 。而 axios 在 Node 环境下底层走的是 http 模块,在浏览器环境下走的是 XMLHttpRequest------两个都不是 Workers 原生支持的。

为了让 axios 在 Workers 里跑起来,我们不得不引入适配层。这完全是在为一个本不需要的抽象层擦屁股。


只需要 30 行代码,替掉整个 axios

下面是我们团队现在用的请求封装。没有花哨的设计模式,就是一个纯函数,逻辑全部可见,没有隐藏的拦截器链:

typescript 复制代码
// lib/request.ts

interface RequestConfig extends RequestInit {
  timeout?: number;
  params?: Record<string, string | number | boolean>;
}

interface ApiResponse<T = unknown> {
  code: number;
  data: T;
  message: string;
}

class RequestError extends Error {
  status: number;
  response: Response;
  
  constructor(message: string, status: number, response: Response) {
    super(message);
    this.name = 'RequestError';
    this.status = status;
    this.response = response;
  }
}

const BASE_URL = import.meta.env.VITE_API_BASE ?? '';

export async function request<T = unknown>(
  url: string,
  config: RequestConfig = {},
): Promise<ApiResponse<T>> {
  const { timeout = 10_000, params, headers, ...rest } = config;

  // 拼接查询参数
  const queryString = params
    ? '?' + new URLSearchParams(
        Object.entries(params).map(([k, v]) => [k, String(v)])
      ).toString()
    : '';

  // 超时控制
  const signal = config.signal ?? AbortSignal.timeout(timeout);

  // 鉴权注入
  const token = localStorage.getItem('access_token');
  const mergedHeaders: HeadersInit = {
    'Content-Type': 'application/json',
    ...(token ? { Authorization: `Bearer ${token}` } : {}),
    ...headers,
  };

  const response = await fetch(`${BASE_URL}${url}${queryString}`, {
    ...rest,
    headers: mergedHeaders,
    signal,
  });

  // fetch 不会在 4xx/5xx 时 reject,需要手动处理
  if (!response.ok) {
    throw new RequestError(
      `HTTP ${response.status}: ${response.statusText}`,
      response.status,
      response,
    );
  }

  return response.json() as Promise<ApiResponse<T>>;
}

业务代码的调用方式:

typescript 复制代码
// 普通 GET
const { data: userList } = await request<User[]>('/api/users', {
  params: { page: 1, size: 20 },
});

// POST
const { data: result } = await request<CreateResult>('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'ErpanOmer', role: 'admin' }),
});

// 带手动取消的请求(用于搜索框等竞态场景)
const controller = new AbortController();
const { data } = await request<SearchResult[]>('/api/search', {
  params: { keyword: 'react' },
  signal: controller.signal,
});
// 需要取消时
controller.abort();

对比一下改造前后的依赖变化:

diff 复制代码
// package.json
{
  "dependencies": {
-   "axios": "^1.7.2",
-   "axios-retry": "^4.1.0",
  }
}

两个包删掉,gzip 后减少约 7 KB 。7 KB 听起来不多,但如果你的项目还挂着 lodash、moment、antd 全量引入,这种顺手减负积少成多,对移动端首屏性能的贡献是实打实的🫡。


fetch 封装必须踩得坑,讲清楚

别急着全盘照抄上面那 30 行。原生 fetch 有几个坑,如果你不处理,上了生产一定会翻车。

fetch 只在网络层失败时 reject

这是 fetch 和 axios 最大的行为差异 。axios 在收到 4xx / 5xx 响应时会直接走 catch,但 fetch 不会。对 fetch 来说,只要服务端返回了响应(哪怕是 500),就算请求成功。

所以上面那段 if (!response.ok) 的判断是生命线 ,绝对不能省。漏了这一步,你的 try/catch 会安静地吞掉所有服务端错误,页面一片祥和,数据全是空的 🤷‍♂️。

Token 过期后的无感刷新

如果你的系统有 Token 刷新机制,在 axios 里通常是挂一个 response 拦截器来做。在 fetch 封装里同样可以实现,而且逻辑更加透明:

typescript 复制代码
// 在 request 函数内部,response.ok 判断之前加入
if (response.status === 401) {
  const refreshed = await tryRefreshToken();
  if (refreshed) {
    // 用新 Token 重发原请求
    const newToken = localStorage.getItem('access_token');
    const retryHeaders = { ...mergedHeaders, Authorization: `Bearer ${newToken}` };
    const retryResponse = await fetch(`${BASE_URL}${url}${queryString}`, {
      ...rest,
      headers: retryHeaders,
      signal, // 注意:复用原来的 signal,保证外层取消仍然生效
    });
    if (!retryResponse.ok) {
      throw new RequestError('Retry failed', retryResponse.status, retryResponse);
    }
    return retryResponse.json() as Promise<ApiResponse<T>>;
  }
  // 刷新失败,踢回登录页
  window.location.href = '/login';
  throw new RequestError('Session expired', 401, response);
}

这段逻辑在 axios 的拦截器里也能做,但区别是:拦截器里的逻辑是隐藏的,你需要打开一个单独的文件去找;而函数封装里的逻辑是线性的,你从上往下读就能看到完整的请求生命周期。

并发请求时的 Token 刷新竞态

如果页面同时发了 5 个请求,其中第 1 个发现 Token 过期开始刷新,此时第 2 到第 5 个请求怎么办?如果它们也各自触发刷新,就会同时发出 5 个 refresh 请求,导致旧的 Refresh Token 被覆盖、多端登录态混乱。

兜底方案是加一个刷新锁:

typescript 复制代码
let refreshPromise: Promise<boolean> | null = null;

async function tryRefreshToken(): Promise<boolean> {
  // 如果已经有一个刷新请求在飞,复用它的 Promise
  if (refreshPromise) return refreshPromise;
  
  refreshPromise = (async () => {
    try {
      const res = await fetch(`${BASE_URL}/api/auth/refresh`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          refreshToken: localStorage.getItem('refresh_token'),
        }),
      });
      if (!res.ok) return false;
      const { data } = await res.json();
      localStorage.setItem('access_token', data.accessToken);
      localStorage.setItem('refresh_token', data.refreshToken);
      return true;
    } catch {
      return false;
    } finally {
      refreshPromise = null; // 刷新完成,释放锁
    }
  })();
  
  return refreshPromise;
}

这段代码保证:无论同时有多少个请求触发了 401,refresh 只会发一次,所有后续请求都在等同一个 Promise。 这一层在 axios 拦截器里同样需要自己写,它并不会帮你解决。

AbortSignal.timeout() 的浏览器兼容

AbortSignal.timeout() 是 2022 年才进入规范的 API。在极少数需要兼容老旧 WebView(比如部分低端安卓设备的内嵌浏览器)的场景下,需要手动 polyfill:

typescript 复制代码
function createTimeoutSignal(ms: number): AbortSignal {
  // 优先使用原生实现
  if ('timeout' in AbortSignal) {
    return AbortSignal.timeout(ms);
  }
  // 降级方案
  const controller = new AbortController();
  setTimeout(() => controller.abort(new DOMException('Timeout', 'TimeoutError')), ms);
  return controller.signal;
}

绝大多数现代浏览器已经支持,但兜底一定要写!


什么时候 axios 仍要使用?

我不是要把 axios 一棒子打死。以下场景它仍然有优势:

  • 上传进度监控 :fetch 目前没有原生的上传进度 API(下载进度可以用 ReadableStream),如果你的业务有大文件上传且需要显示进度条,XMLHttpRequest 的 upload.onprogress 目前仍然是唯一方案。
  • 遗留项目迁移成本过高 :如果你的项目有上百处 axios 调用且工作良好,纯粹为了干净去替换,投入产出比不划算。规范约束新代码即可。

但对于 2026 年的新项目来说,fetch 就是那个你不需要安装的、零成本的、全运行时兼容的标准答案。

好了,今天就分享到这里🙌


喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。

主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

微信扫码关注 👆

不定期更新,不刷屏,聊点真正有用的干货。

相关推荐
兮动人1 小时前
Chrome 152 升级后本地请求全挂了?是 LNA 策略在搞事
前端·chrome·lna·local network
一只游鱼1 小时前
我用原生 JS 做了一个「会呼吸」的博客:Canvas 水境 + 合成音效 + 会破裂的留言气泡(vibe coding)
javascript·agent·canvas·github pages
JarvanMo1 小时前
让 3D 在 Flutter 中焕发生机:Flutter GPU 与 flutter_scene 实战经验
前端
SimonKing2 小时前
别再找笔记了,试试这款轻量级的开源软件:local-notes
后端·程序员·开源
狂师2 小时前
太强了!GitHub上开源了一款AI自动化测试框架,0代码搞定前后端测试!
前端·后端·测试
只睡四小时2 小时前
Local-First 数据契约:一张表装下 4 类数据
前端·javascript·架构设计·local-first·数据契约
迅猛龙办公室2 小时前
Python函数实现反转一个只有3位数的整数
开发语言·前端·python
我星期八休息2 小时前
Redis 的原理和使用(2)
前端·数据库·redis·python·mysql·缓存·bootstrap
Java后端的Ai之路2 小时前
19-ReactJS最佳实践
前端·react.js·前端框架