封装fetch替换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();

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

typescript 复制代码
// 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 封装里同样可以实现,而且逻辑更加透明:

javascript 复制代码
// 在 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 就是那个你不需要安装的、零成本的、全运行时兼容的标准答案。

相关推荐
用户461169657281 小时前
网站 HTTPS 不稳定?证书排查指南
前端
n87651 小时前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
keyipatience1 小时前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge20252 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
wangruofeng2 小时前
2026 Web 技术选型指南,AI 时代如何选择适合项目的技术栈
前端·aigc·ai编程
honkun62 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
爱勇宝3 小时前
研究数字孪生的这半年,做了个 Demo,也有了一些新想法
前端·程序员
不可能片场3 小时前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
前端小万3 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试