从 request 到原生 fetch,Node.js 的 HTTP 客户端简史
如果你维护过一段时间 Node.js 服务端项目,应该对这样的历史很熟悉:最早大家用 request 库,后来它停止维护,社区主流逐渐转向 axios 和 node-fetch。在那段岁月里,Node.js 官方一直没有提供一个足够高层的 HTTP 客户端,开发者要么在 http 模块之上手写封装,要么把第三方包变成核心依赖。

真正让局面改变的是 Node.js 18。它默认带上了全局 fetch,底层实现来自 undici。这意味着一个普通 Node 服务可以不引入任何 HTTP 依赖就发起请求。现在回头看,这件事不只是少装一个包,它背后是 Node.js 平台对 HTTP 客户端层的一次重新设计。
undici 不是一个简单的 HttpClient
第一次接触 undici 的人,很容易把它误认为 fetch 的 polyfill。实际上 undici 是 Node.js 平台上一个独立的 HTTP/1.1 客户端实现,它的内部包含连接池、请求调度、头部解析、流式数据处理等完整能力,而 fetch 只是它对外暴露的、符合 Web 标准的包装层。
undici 的名字来源于意大利语的 11,暗指它比 Node.js 原本的 http 客户端快一个数量级。这个“快”不是靠玄学,而是来自几个关键选择:绕开 http 模块的历史包袱,直接在 socket 层处理协议;重新实现头部解析,避免旧有实现中的一些冗余步骤;把连接复用和请求排队做得更精细。Node 团队选择用 undici 来实现内置 fetch,并不是为了让 API 看起来标准,而是希望标准 API 在 Node 里也能获得接近底层调用的性能。
这里需要区分两个概念:你日常调用的全局 fetch 是 undici 的高层 API,而 undici 本身还提供 request、stream、Agent、Client 等底层接口。对普通业务请求来说,两者体验差别不大。但当你需要限制连接数、处理超大响应、或要应对极高并发时,底层接口的价值就会显现。
原生 fetch、node-fetch、axios,差异比想象中大
很多人会有一种直觉:原生 fetch 和 node-fetch 应该差不多,axios 也只是封装得更好看。从调用形式看,确实相似,但到了工程层面,差异会被放大成线上事故。
| 维度 | 原生 fetch / undici | node-fetch | axios |
|---|---|---|---|
| API 标准 | 标准 fetch 规范 | 基于旧版 fetch 规范 | 自定 API,与 fetch 不同 |
| 默认超时 | 无,需要自行实现 | 无,需要自行实现 | 有 timeout 配置 |
| HTTP/2 支持 | 底层支持,fetch 层仍在演进 | 不支持 | 不支持 |
| 连接池调度 | 内置 Agent,可替换可配置 | 依赖 http 模块的 Agent | 依赖 http(s).Agent |
| 非 2xx 处理 | 不抛错 | 不抛错 | 默认抛错 |
| 依赖体积 | 0 | 约几十 KB | 超过 200KB |
这张表里最值得关注的是默认超时和非 2xx 处理。axios 把这两件事默认做好了,而原生 fetch 将控制权完全交给你。一个没有判断 res.ok 的服务,很容易把上游 502 当成正常响应继续处理,最后在数据核对时才发现污染。另一个容易忽视的点是,axios 会默认发送带有语义的 User-Agent,而 fetch 不会。有些后端会根据 UA 做访问控制或限流,切换后第一次请求就失败。
node-fetch 虽然没有 axios 的默认超时,但很多老项目会自己封装一层,所以遇到的问题相对可控。真正需要警惕的,是团队从 node-fetch 切换到原生 fetch 时,只看到 API 兼容,但没有意识到连接行为和错误语义已经换了底盘。
工程现场最常踩的三个坑
如果你已经在新项目里使用原生 fetch,或者正在计划迁移,下面这些问题大概率会遇到。
响应体只能消费一次
fetch 的 Response 对象基于标准 ReadableStream,body 只能被读取一次。先调 res.text() 再调 res.json() 会直接抛错。这个问题在调试时最常见,为了看响应内容打印了 res.text(),正式逻辑却还在等 res.json() 返回数据。
更隐蔽的是,如果封装的函数读了一次 body 用于日志,然后把 Response 对象返回给调用方,调用方会拿到空的响应体。比较好的做法是在封装层统一处理 body,解析后返回业务对象,或者把读取过的文本缓存起来。
超时要自己做,注意 TimeoutError 和 AbortError 的区别
axios 的 timeout 开箱即用,而 fetch 需要借助 AbortSignal。Node.js 较新版本提供了 AbortSignal.timeout(),写起来很直接:
const res = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000),
});
try {
const data = await res.json();
} catch (err) {
if (err.name === 'TimeoutError') {
// 请求超时
} else if (err.name === 'AbortError') {
// 主动取消
}
}
AbortSignal.timeout() 抛出的异常是 TimeoutError,而手动调用 abort() 抛出的是 AbortError。如果你只判断 AbortError,超时会被当成未处理异常。这个问题在真实项目里很容易漏掉,因为超时在测试环境中并不容易稳定复现。
建议在统一封装里把这两种异常映射成业务异常,再决定是否重试或降级。不要把 fetch 的错误处理散落在每个业务模块中。
连接行为需要额外关心
原生 fetch 使用 undici 的全局 Agent,默认开启 keep-alive,这本身是好事。但在高并发场景下,默认的 Agent 行为不一定适合你的上游。一个典型问题是,所有请求共享同一个连接池,某个上游变慢时,其他服务的请求也可能被拖住。
另一种情况是连接数不受控。有些服务在高峰期会出现大量 TIME_WAIT,就是因为没有对 Agent 做任何限制。建议在启动阶段显式配置全局 dispatcher:
import { Agent, setGlobalDispatcher, fetch } from 'undici';
const agent = new Agent({
connections: 200,
pipelining: 1,
keepAliveTimeout: 30e3,
});
setGlobalDispatcher(agent);
const res = await fetch('https://api.example.com');
如果你有多个上游,且每个上游的容量差异很大,可以按域名创建不同的 dispatcher,并传入请求的 dispatcher 选项。这个做法更精细,但大多数服务的资源规模用不到。
什么时候该用 axios,什么时候该换原生 fetch
讲到这里,你可能想问:有没有一个标准答案告诉我该换还是不该换?我的判断是:除非有明确的收益,否则不要为了原生而原生。
如果项目已经深度依赖 axios 的拦截器、实例配置、取消令牌、自定义适配器这些能力,迁移到 fetch 的代价会很高。拦截器在 fetch 中没有直接对应物,取消机制也不同。强行替换会让原本内聚的逻辑被打散,产生新的维护成本。
但如果你属于下面几类情况,原生 fetch 很值得认真考虑:
- 新服务,并且 Node 版本可以选择 18 以上的 LTS;
- 希望削减依赖体积,让镜像和部署包更小;
- 希望核心请求代码能在 Node 与浏览器之间共用;
- 团队有能力自己封装超时、重试和错误处理。
还有一个经常被忽略的场景:边缘运行时。Cloudflare Workers、Bun、Deno 都以 fetch 为基础 API,如果你的请求层从一开始就用标准 fetch,未来要迁移到边缘环境会顺利很多。这是 axios 很难提供的迁移优势。
几条务实的落地建议
如果你准备在一个新服务里全面采用原生 fetch,这些经验可以帮你少走弯路。
- 集中封装:不要每个模块裸写 fetch。统一维护一个 request 函数,在内部配置超时、检查状态码、决定重试策略。下面是一个很基础但可用的封装:
async function request(url, options = {}) { const res = await fetch(url, { ...options, signal: options.signal || AbortSignal.timeout(5000), }); if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status}: ${text}`); } return res.json(); }这个封装解决了超时管理,以及错误响应被静默吞掉的问题。把它放到项目公共模块,业务代码只需要调用 request(‘/user’) 就好。
- 配置全局 Agent:如果服务访问多个上游,或者对连接数有要求,启动时设置好全局 dispatcher,避免连接数在高峰期失控。
- 大响应不要用 fetch 一把梭:fetch 会把整个响应体读入内存,下载几百 MB 文件时会很危险。最好使用 undici 的 stream(),或者直接处理 ReadableStream。
- 关注 Node 版本的 fetch 稳定性:fetch 在 Node 18 中还是实验特性,Node 21 后才逐渐成为稳定能力。生产环境至少使用受支持的 LTS,并提前确认 fetch 的行为变化。
HTTP 客户端的核心问题,其实不是“哪个库更标准”,而是“当网络异常、上游变慢、并发升高时,你的代码会不会崩溃”。undici 给了 Node.js 一个现代化的起点,但决定系统稳定性的,仍然是你对超时、连接和错误语义的处理方式。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/576/