12k
All articles

将 React 应用从 Axios 迁移到 Fetch

将 React 应用从 Axios 迁移到 fetch,使用安全封装、错误处理、超时和可直接复制的客户端,避免回归。

OpenReplay Team
OpenReplay Team
将 React 应用从 Axios 迁移到 Fetch

将 React 应用从 Axios 迁移到 fetch如果你在封装层后面替换库而不是重写每个调用点,这是一个低风险的变更——但如果你不这样做,则会是一个充满回归风险的雷区。这两个客户端以不同的方式解决同一个问题:fetch 是原生的、无依赖的浏览器标准,提供给你原始的构建材料;而 Axios 则将一系列便利功能打包在一起——基础 URL、自动 JSON 处理、错误响应自动拒绝、response.data、拦截器和超时——这些都需要你自己重新实现。本指南首先解答”是否应该迁移”的问题,然后为真实的 React 应用提供一条有序的、防回归的迁移路径,包括一个完整的、可直接复制使用的客户端实现。

核心要点

  • 最危险的单一差异fetch() 不会在 HTTP 4xx 或 5xx 时拒绝 Promise——它会成功 resolve 并返回一个 ok: falseResponse 对象,因此除非你自己检查 response.ok,否则一个 500 响应会像 200 一样顺利通过你的 try 块。
  • 迁移封装层,而非调用点:如果你的新 fetch 客户端暴露相同的 get/post/patch/delete 签名并返回相同的 Promise,React Query、你的 mutation 以及错误处理逻辑永远不会察觉 Axios 已被移除。
  • 使用 AbortSignal.timeout(8000)fetch 提供 Axios 内置的请求超时功能——自 2024 年 4 月起,该特性已在各主流浏览器中成为基线标准,超时时会以 TimeoutError 中止请求。
  • fetch 不再是浏览器专属:自 Node.js v21 起,它已成为稳定的全局 API,这消除了过去”在 Node 中保留 Axios”建议的最后一个理由。
  • 顺序至关重要:先将所有 import 切换到新客户端,再移除 axios 并刷新 lockfile——顺序颠倒会导致迁移过程中构建失败。

Axios vs Fetch:结论

当你只使用 Axios 的一小部分功能且希望减少运行时依赖时,选择 fetch;当你深度依赖其高级特性时,保留 Axios。 Axios 免费提供基础 URL、自动 JSON 处理、失败响应自动拒绝、response.data 以及拦截器;使用 fetch 时,你只需重新实现你的应用实际用到的那部分功能——仅此而已。对于大多数已将请求集中在单一 API 模块中的 React SPA 来说,这部分功能通常很少。

迁移的动机是具体且实在的:

  • 包体积。 Axios 是一个额外的运行时依赖——根据版本不同,压缩并 gzip 后大约为 13–14 KB——而一个 fetch 封装只是几百行你自己掌控的代码,可以进行 tree-shaking。具体数字会随版本变化,建议使用 Bundlephobia 等工具针对你安装的版本进行实测,而不是依赖固定数字。
  • 减少一个依赖 / 缩小供应链攻击面。 减少依赖本身也是一种安全策略。2026 年 3 月,攻击者通过入侵一个维护者账号,向 npm 发布了两个带有后门的 Axios 版本,所有使用浮动版本范围的项目在下次安装时都会拉取到这些恶意版本。维护者的事后分析(GitHub issue #10636)微软的安全公告均确认,这是一次发布渠道/账号入侵事件——两个恶意发布版本,而非 Axios 源代码本身的漏洞——恶意版本在数小时内被下架;CISA 也发布了相关警报。这件事的教训不是”Axios 不安全”,而是少一个依赖就少一个可能被攻击者利用的账号。
  • 你只用到了其中一小部分。 如果你的代码只是 axios.get(url).then(r => r.data),你其实是在为一个库付出代价,而它做的事情用八行 fetch 代码就能完成。

何时保留 Axios

如果以下任何一条符合你的应用情况,保留 Axios——迁移不值得:

如果你依赖…为何 fetch 会带来麻烦
大量使用请求/响应拦截器fetch 没有拦截器系统,你需要自己重建
上传进度 UIfetch 的请求体不暴露上传进度事件,你需要使用 XMLHttpRequest
旧版浏览器支持(如 IE11)fetch 在这些浏览器中从未可用
大量 Axios 专有配置(paramsSerializer、transformers、adapters)重新实现的成本超过移除依赖带来的收益

有一个理由不再属于这个列表:Node.js。fetch 不再是浏览器专属:Node.js 18(2022 年 4 月)默认启用了全局 fetch,Node.js 21(2023 年 10 月)fetch 和 Web Streams 提升为稳定 API。如需更深入的逐特性对比,LogRocket 的 Axios vs Fetch 详细对比涵盖了下载进度、流式传输和请求取消等内容。

你必须弥补的行为差异

Axios 隐式处理了六件事,而 fetch 不会——每一件都是一个静默的行为变化,代码能编译运行,但行为不同。在编写封装层之前,务必彻底理解这些差异。

  1. fetch 不会在 4xx/5xx 时拒绝 Promise。 这是最大的陷阱。根据 MDN 的 Fetch API 参考文档,只要服务器返回了响应头,Promise 就会 resolve——即使是 HTTP 错误状态码。500 响应会以 response.ok === false 的形式 resolve。Axios 会将非 2xx 状态自动拒绝到 .catch() 中;fetch 不会。你必须自己检查 response.ok 并手动抛出错误。
  2. 没有自动 JSON 处理。 Axios 会自动序列化请求体并解析响应。使用 fetch 时,你需要在发送时调用 JSON.stringify(),在接收时调用 await response.json(),并手动设置 Content-Type: application/json
  3. 没有 baseURL Axios 会自动拼接配置的基础 URL。fetch 接受完整 URL 或相对于文档的路径,路径拼接需要你自己处理。
  4. 没有 response.data 解析后的响应体通过 await response.json() 获取,而不是挂载在 .data 属性上。
  5. 没有拦截器。 没有内置的钩子用于全局附加认证 token 或处理 401 响应。
  6. 没有超时选项。 Axios 有 timeout 配置;fetch 没有。你需要提供 AbortSignal

前后对比

同一个请求,分别用 Axios 和你将要构建的客户端来实现:

// Axios — 错误自动拒绝,response.data,自动 JSON 处理
const { data } = await axios.get(`/users/${id}`);
return data;

// fetch(原生)— 必须检查 ok,必须手动解析,没有 baseURL
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(res.statusText); // <-- 没有这行,500 会被当作成功处理
return res.json();

策略:迁移封装层,而非调用点

不要修改你的组件、hooks 或 React Query 调用。在现有 API 接口背后替换库的实现。 如果你的应用已经将请求集中在一个暴露 get/post/patch/delete 的单一模块中,你只需让新的实现在行为上保持兼容——而不是与 Axios 兼容。保持相同的签名、返回相同的 Promise、抛出相同类型的错误,下游的所有代码就无需改动。这正是让迁移变得平稳而非充满风险的关键。

迁移步骤:

  1. 审查现有用法。 确认所有请求都通过一个封装层发出。如果不是,你的第一个 PR 应该是将它们统一路由到一个模块——这个重构与 fetch 无关,可以独立安全地发布。
  2. 匹配错误契约。 如果你的 UI 根据 error.code === 'EXPIRED'error.status 进行分支处理,你的新客户端必须产生包含相同字段的错误对象。这里的不匹配会造成静默的行为破坏。
  3. 保持 Promise 结构一致。 React Query、SWR 以及你的 mutation 关心的是:你返回一个 Promise,它要么 resolve 为数据,要么 reject 为错误——而不关心是哪个库产生的。

一个完整的 fetch 客户端,可直接复制使用

这个完整的 api-client.ts 弥补了对比中的所有差异:基础 URL 拼接、查询参数、请求头合并、JSON 请求体、用于拒绝错误响应的 ok 检查、自定义错误类、认证 token 注入、集中式 401 处理,以及通过 AbortSignal.timeout() 实现的超时。将它放入项目,将 import 指向它,你的调用点即可继续正常工作。

// lib/api-client.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
const DEFAULT_TIMEOUT = 8000;

export class ApiClientError extends Error {
  status: number;
  code?: string;
  data?: unknown;
  constructor(message: string, status: number, code?: string, data?: unknown) {
    super(message);
    this.name = "ApiClientError";
    this.status = status;
    this.code = code;
    this.data = data;
  }
}

type Query = Record<string, string | number | boolean | undefined>;

interface RequestOptions extends Omit<RequestInit, "body"> {
  body?: unknown;
  params?: Query;
  timeout?: number;
}

function buildUrl(path: string, params?: Query): string {
  const base = BASE_URL.endsWith("/") ? BASE_URL : `${BASE_URL}/`;
  const url = new URL(path.replace(/^\//, ""), new URL(base, window.location.origin));
  if (params) {
    for (const [key, value] of Object.entries(params)) {
      if (value !== undefined) url.searchParams.set(key, String(value));
    }
  }
  return url.toString();
}

async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { body, params, timeout = DEFAULT_TIMEOUT, headers, ...rest } = options;
  const token = localStorage.getItem("token");

  let res: Response;
  try {
    res = await fetch(buildUrl(path, params), {
      ...rest,
      headers: {
        "Content-Type": "application/json",
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...headers,
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(timeout),
    });
  } catch (err) {
    // Chromium 可能将 fetch 超时以 AbortError 而非 TimeoutError 的形式抛出。
    if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) {
      throw new ApiClientError("Request timed out", 0, "TIMEOUT");
    }
    throw new ApiClientError("Network error", 0, "NETWORK");
  }

  // 陷阱所在:fetch 在 4xx/5xx 时仍会 resolve。需要显式拒绝。
  if (!res.ok) {
    let payload: any;
    try { payload = await res.json(); } catch { payload = undefined; }

    if (res.status === 401) {              // 拦截器等价实现:集中处理 401
      localStorage.removeItem("token");
      window.location.assign("/login");
    }
    throw new ApiClientError(
      payload?.message ?? res.statusText,
      res.status,
      payload?.code,
      payload,
    );
  }

  if (res.status === 204) return undefined as T;
  return (await res.json()) as T;
}

export const api = {
  get:    <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: "GET" }),
  post:   <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "POST", body }),
  patch:  <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "PATCH", body }),
  put:    <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "PUT", body }),
  delete: <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: "DELETE" }),
};

有两个细节值得特别说明。超时使用了 AbortSignal.timeout(),它返回一个在指定时间后自动中止的 AbortSignal,超时时会以 TimeoutError DOMException 中止;自 2024 年 4 月起,该特性已在最新设备和浏览器版本中全面可用。需要同时捕获 TimeoutErrorAbortError:在基于 Chromium 的浏览器中,fetch 超时可能以 AbortError 而非 TimeoutError 的形式出现,因此需要防御性地处理两种名称。204 的短路处理避免了对空响应体调用 .json()

重建拦截器——以及哪些应保留在 XHR 中

你的封装层本身就是你的拦截器。 Axios 拦截器所做的一切——附加 Bearer token、规范化错误、在 401 时重定向——都存在于上面的 request() 函数中,并通过构造方式应用于每次调用。你不需要插件系统;你需要一个统一的入口,而你已经有了。添加 429 重试或请求日志记录,只需在 request() 中加几行代码,而不是注册一个处理器。

有一件事要刻意迁移:上传进度。 浏览器的 fetch 请求体不像 XMLHttpRequest 那样暴露上传进度事件,因此请保留你现有的基于 XHR 的上传流程。混合使用完全没有问题:fetch 处理 95% 的 JSON 请求,XHR 处理文件上传进度条。

迁移步骤

按顺序执行以下步骤。顺序错误是人们最常犯的失误。

  1. 先切换 import。 将每个调用点指向新客户端(import { api } from "@/lib/api-client")。由于签名匹配,这是一次查找替换,而非重写。
  2. 移除 Axios,然后刷新 lockfile。package.json 中删除 axios,然后运行 npm install(或你的包管理器的等效命令)以更新 lockfile。务必在切换 import 之后再执行此步骤——顺序颠倒会导致迁移过程中因 import 无法解析而构建失败。截至 2026 年 6 月,npm registry 上 Axios 的最新版本为 1.18.1;如果你在其他地方固定了一个安全版本而非移除它,请查看 registry 确认当前发布版本。
  3. 通过构建验证,而非通过阅读代码验证。 运行你的测试套件、TypeScript 类型检查(tsc --noEmit)以及生产构建。迁移完成的标志是应用能够构建、通过类型检查并正常运行——而不是 diff 看起来干净。

最后这一点正是静默回归的藏身之处。Axios→fetch 最危险的回归是未处理的错误响应:一个返回 500 的请求,由于 fetch 不抛出错误,以”成功”的形式流过,导致用户面对一个空白或损坏的 UI,却没有任何错误提示。单元测试模拟了正常路径并通过;服务器日志显示了 500,所以后端看起来没问题。“服务器记录了错误”与”用户什么都没看到”之间的差距,正是带有网络捕获功能的会话回放工具所能发现的——它将捕获到的错误响应与用户实际经历的损坏 UI 关联起来,捕获那些测试和服务器日志各自单独都无法发现的回归问题。上面客户端中的 !res.ok 检查是从根源上防止此类 bug 的手段;在生产环境中验证它是否正常触发,才是确认迁移真正完成的方式。

基于 fetch 的客户端为你带来更小的依赖树、对错误和认证行为的完全掌控,以及供应链中少一个第三方账号的风险——而无需重写任何一个组件,前提是你保持 API 接口完全一致。下一步的具体行动:如果你的应用请求还没有通过单一模块发出,先完成这个整合,然后将上面的客户端替换进去,让你的构建、类型检查和测试来告诉你何时大功告成。

常见问题

fetch 在收到 404 或 500 响应时会抛出错误吗?

不会。fetch 只在网络故障或请求未能完成时才会拒绝其 Promise;HTTP 404 或 500 会成功 resolve,返回一个 ok 属性为 false 的 Response 对象。这意味着除非你显式检查 response.ok 并手动抛出错误,否则服务器错误会像成功一样流过你的 try 块。Axios 的行为不同,它会将任何非 2xx 状态自动拒绝到 catch 路径中。

在 Node.js 中发起请求还需要 Axios 吗?

不需要。自 2023 年 10 月发布的 Node.js 21 起,fetch 已成为稳定的全局 API;自 2022 年 4 月发布的 Node.js 18 起,它就已作为默认启用的实验性全局 API 可用。过去那种“在服务端请求中保留 Axios”的建议已不再适用。你可以在浏览器和 Node.js 代码中使用同一个基于 fetch 的客户端,不过 18 之前的旧版 Node.js 仍然需要 polyfill 或第三方库。

如何为 fetch 设置请求超时?

将 AbortSignal.timeout(毫秒数) 作为 signal 选项传入,例如 signal: AbortSignal.timeout(8000)。它返回一个在指定时间后自动中止的 signal,并以 TimeoutError DOMException 拒绝 fetch Promise。自 2024 年 4 月起,该特性已在各主流浏览器中成为基线标准。在基于 Chromium 的浏览器中,拒绝可能以 AbortError 而非 TimeoutError 的形式出现,因此在区分超时与手动取消时,需要防御性地捕获两种名称。

从 Axios 切换到 fetch 会破坏我的 React Query 或 SWR 设置吗?

不会,只要你的新 fetch 客户端返回相同的 Promise 并暴露相同的方法签名。React Query、SWR 以及你的 mutation 只关心调用是否 resolve 为数据或 reject 为错误,而不关心是哪个库产生的。保持 get、post、patch 和 delete 签名完全一致,并抛出包含相同字段的相同错误类型供 UI 进行分支判断,数据获取层就永远不会察觉 Axios 已被移除。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.