2026 年,Node.js 内置的 fetch() 已经成为大多数开发者的默认选择——但当你的应用进入生产环境,面对连接池管理、请求超时、自动重试、Mock 测试这些工程化需求时,一个裸 fetch() 就显得力不从心了。根据 npm 下载数据,undici 周下载量已突破 1.2 亿次,它正是 Node.js 内置 fetch() 的底层引擎,但大多数开发者从未直接使用过它。
这篇文章将从工程实战角度,深度对比 Node.js 生态中 5 大 HTTP 客户端方案,帮你做出生产级的技术选型。
🔧 一、五大 HTTP 客户端全景对比
Node.js 生态中 HTTP 客户端经历了从 http.request 到 request(已废弃)、axios、node-fetch、got,再到如今 undici + 内置 fetch 的演变。2026 年的格局已经相当清晰。
1.1 候选方案概览
| 方案 | 类型 | TypeScript | HTTP/2 | 连接池 | 周下载量 | 维护状态 |
|---|---|---|---|---|---|---|
| undici | 独立库 | ✅ 内置 | ✅ | ✅ 高级 | ~1.2 亿 | ✅ Node.js 官方 |
| ofetch | unjs 生态 | ✅ 内置 | ❌ | ❌ | ~800 万 | ✅ 活跃 |
| ky | 轻量封装 | ✅ 内置 | ❌ | ❌ | ~600 万 | ✅ 活跃 |
| got | 全功能 | ✅ 类型 | ❌ | ✅ 基础 | ~4000 万 | ⚠️ 维护模式 |
| axios | 老牌方案 | ⚠️ 需装 | ❌ | ✅ 基础 | ~1 亿 | ✅ 活跃 |
⚠️ 警告:
node-fetch和request已不再推荐使用。Node.js 18+ 内置了fetch(),request早在 2020 年就已废弃。
1.2 关键差异分析
undici 之所以值得关注,是因为它不是简单的 fetch 封装——它是 Node.js 官方维护的 HTTP/1.1 和 HTTP/2 客户端实现,直接用 C++ 绑定 llhttp 解析器,跳过了 http.request 的大量兼容层。Node.js 内置的 fetch() 就是基于 undici 实现的。
ofetch 是 Nuxt 团队(unjs)出品的轻量方案,它的核心优势是开箱即用:自动 JSON 解析、自动重试、友好的错误处理。如果你在用 Nuxt 或 Nitro,ofetch 已经是默认的 HTTP 客户端。
axios 在 2026 年仍然是下载量最高的方案之一,但它的架构(基于 XMLHttpRequest 的浏览器兼容层、拦截器系统)在现代 Node.js 中已经显得冗余。更关键的是,axios 的 TypeScript 类型推断一直是痛点——response.data 默认是 any,需要手动指定泛型。
💡 **提示:**如果你的项目同时需要浏览器端和 Node.js 端的 HTTP 请求,且不需要连接池等高级特性,直接用内置
fetch()就够了。下面重点讨论需要更精细控制的场景。
🚀 二、Undici 深度实战
undici 的 API 设计分为两层:高层 API(fetch、request、stream)和底层 API(Client、Pool、Agent)。高层 API 与 Web 标准兼容,底层 API 提供了生产环境需要的所有控制能力。
2.1 连接池与 Agent 配置
连接池是 undici 最核心的能力。默认情况下,每个 fetch() 调用都会创建一个新连接,这在高频请求场景下会导致严重的性能问题。
// undici 连接池配置示例
import { Agent, Pool, fetch as undiciFetch } from 'undici'
// 创建一个带连接池的 Agent
const agent = new Agent({
keepAliveTimeout: 10_000, // 空闲连接保持 10 秒
keepAliveMaxTimeout: 600_000, // 最大保持 10 分钟
connections: 100, // 最大连接数
pipelining: 1, // HTTP/1.1 管线化深度(1 = 禁用)
connect: {
timeout: 5_000, // TCP 连接超时 5 秒
rejectUnauthorized: true, // 验证 SSL 证书
},
})
// 使用 Agent 发起请求(自动复用连接)
const response = await undiciFetch('https://api.example.com/data', {
dispatcher: agent,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'test' }),
headersTimeout: 10_000, // 响应头超时
bodyTimeout: 30_000, // 响应体超时
})
const data = await response.json()
console.log(data)
📌 记住:
dispatcher是 undici 的核心概念。每个请求都需要指定一个 dispatcher(Agent、Pool 或 Client),它决定了连接如何被管理。全局fetch()默认使用一个全局 Agent,但你无法精细控制它的参数。
2.2 超时控制的三层架构
生产环境中,超时控制是最容易被忽视也最容易出问题的地方。undici 提供了三层超时机制:
// undici 三层超时控制
import { Agent } from 'undici'
const agent = new Agent({
// 第一层:TCP 连接超时
connect: {
timeout: 3_000, // 3 秒内必须建立 TCP 连接
},
})
const response = await fetch('https://api.example.com/slow', {
dispatcher: agent,
// 第二层:响应头超时(从发送请求到收到第一个字节)
headersTimeout: 10_000,
// 第三层:响应体超时(两个数据块之间的最大间隔)
bodyTimeout: 30_000,
// 全局请求超时(AbortSignal 方式)
signal: AbortSignal.timeout(60_000),
})
| 超时类型 | 默认值 | 作用 | 推荐值(API 调用) |
|---|---|---|---|
connect.timeout |
10 秒 | TCP 握手超时 | 3-5 秒 |
headersTimeout |
30 秒 | 等待响应头 | 10-15 秒 |
bodyTimeout |
30 秒 | 响应块间超时 | 15-30 秒 |
AbortSignal.timeout |
无 | 全局超时 | 30-60 秒 |
⚠️ **警告:**永远不要在生产环境中使用无超时的 HTTP 请求。一个挂起的连接会占用连接池中的槽位,在目标服务故障时可能迅速耗尽所有连接。
2.3 MockAgent:零依赖的 HTTP 测试
undici 内置了 MockAgent,不需要 nock 或 msw 就能 mock HTTP 请求。这在单元测试中非常实用:
// 使用 undici MockAgent 进行 HTTP 测试
import { MockAgent, setGlobalDispatcher } from 'undici'
import { describe, it, expect, beforeEach } from 'vitest'
// 创建 MockAgent 并替换全局 dispatcher
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
// 禁用网络请求(未 mock 的请求会报错)
mockAgent.disableNetConnect()
describe('API Client', () => {
beforeEach(() => {
// 每个测试前清理 mock
mockAgent.disableNetConnect()
})
it('should fetch user data', async () => {
// 设置 mock pool(匹配目标域名)
const mockPool = mockAgent.get('https://api.example.com')
// 设置 mock 请求
mockPool.intercept({
path: '/users/123',
method: 'GET',
}).reply(200, {
id: 123,
name: '张三',
email: 'zhangsan@example.com',
}, {
headers: { 'content-type': 'application/json' },
})
// 发起真实请求(会被 mock 拦截)
const response = await fetch('https://api.example.com/users/123')
const user = await response.json()
expect(user.id).toBe(123)
expect(user.name).toBe('张三')
})
it('should handle timeout errors', async () => {
const mockPool = mockAgent.get('https://api.example.com')
// 模拟延迟响应
mockPool.intercept({
path: '/slow-endpoint',
}).reply(200, 'OK').delay(5_000) // 延迟 5 秒
// 这个请求应该超时
await expect(
fetch('https://api.example.com/slow-endpoint', {
signal: AbortSignal.timeout(1_000),
})
).rejects.toThrow()
})
})
✅ **推荐:**使用 MockAgent 替代 nock/msw 测试 undici/fetch 请求,它与被测代码使用同一套连接管理逻辑,测试结果更可靠。
💡 三、生产环境模式与避坑指南
3.1 重试策略的正确实现
HTTP 重试看似简单,但实现不当会导致「重试风暴」——大量客户端同时重试,把一个短暂的故障放大为持续性过载。
// 带指数退避和抖动的重试策略
import { Agent } from 'undici'
async function fetchWithRetry(url, options = {}) {
const {
maxRetries = 3,
baseDelay = 1_000,
maxDelay = 30_000,
retryOn = [408, 429, 500, 502, 503, 504],
...fetchOptions
} = options
const agent = options.dispatcher || new Agent({
keepAliveTimeout: 10_000,
connections: 50,
connect: { timeout: 5_000 },
})
let lastError
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
...fetchOptions,
dispatcher: agent,
signal: AbortSignal.timeout(30_000),
})
// 检查是否需要重试(可重试的状态码)
if (retryOn.includes(response.status) && attempt < maxRetries) {
// 优先使用 Retry-After 头
const retryAfter = response.headers.get('Retry-After')
if (retryAfter) {
const waitMs = parseInt(retryAfter, 10) * 1000
await sleep(Math.min(waitMs, maxDelay))
continue
}
// 指数退避 + 随机抖动(避免重试风暴)
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
)
await sleep(delay)
continue
}
return response
} catch (error) {
lastError = error
// 只对网络错误重试,不对 abort 重试
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
if (attempt < maxRetries) {
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
)
await sleep(delay)
continue
}
}
// 不可重试的错误直接抛出
if (attempt >= maxRetries) break
}
}
throw lastError
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 使用示例
const response = await fetchWithRetry('https://api.example.com/data', {
maxRetries: 3,
baseDelay: 2_000,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
})
⚠️ **警告:**永远不要对 POST/PUT/DELETE 请求盲目重试。如果请求不是幂等的(idempotent),重试可能导致数据重复。确保你的 API 服务端支持幂等性(如通过 Idempotency-Key 头)。
3.2 请求拦截与统一错误处理
生产环境中,你需要在所有 HTTP 请求上统一添加认证头、日志、指标采集等逻辑。undici 的 Dispatcher 装饰器模式可以优雅地实现这一点:
// 基于 Dispatcher 的请求拦截器
import { Agent, Dispatcher } from 'undici'
class InterceptorDispatcher extends Dispatcher {
#inner
#options
constructor(inner, options = {}) {
super()
this.#inner = inner
this.#options = options
}
async dispatch(opts, handler) {
const startTime = Date.now()
// 注入认证头
if (this.#options.authToken) {
opts.headers = {
...opts.headers,
'Authorization': `Bearer ${this.#options.authToken}`,
}
}
// 注入请求 ID(用于链路追踪)
const requestId = crypto.randomUUID()
opts.headers = {
...opts.headers,
'X-Request-ID': requestId,
}
// 包装 handler 以拦截响应
const wrappedHandler = {
...handler,
onResponseStart: (trial, statusCode, headers) => {
const duration = Date.now() - startTime
// 记录请求指标
console.log(JSON.stringify({
type: 'http_request',
method: opts.method || 'GET',
path: opts.path,
status: statusCode,
duration,
requestId,
}))
// 检查响应状态
if (statusCode >= 500) {
console.error(`[HTTP ${statusCode}] ${opts.method} ${opts.path} (${duration}ms)`)
}
// 调用原始 handler
if (handler.onResponseStart) {
handler.onResponseStart(trial, statusCode, headers)
}
},
onError: (error) => {
const duration = Date.now() - startTime
console.error(`[HTTP Error] ${opts.method} ${opts.path} (${duration}ms):`, error.message)
if (handler.onError) {
handler.onError(error)
}
},
}
return this.#inner.dispatch(opts, wrappedHandler)
}
}
// 使用方式
const agent = new Agent({ keepAliveTimeout: 10_000 })
const interceptedAgent = new InterceptorDispatcher(agent, {
authToken: process.env.API_TOKEN,
})
// 所有通过 interceptedAgent 发起的请求都会自动注入认证头和日志
const response = await fetch('https://api.example.com/data', {
dispatcher: interceptedAgent,
})
3.3 ofetch vs undici:什么时候该选谁?
很多开发者纠结于该用 undici 还是 ofetch。答案取决于你的需求层次:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 简单 API 调用 | 内置 fetch() |
零依赖,够用就行 |
| Nuxt 项目 | ofetch | 已内置,自动 JSON、重试 |
| 需要连接池控制 | undici | Agent/Pool 精细管理 |
| 需要 HTTP/2 | undici | 唯一支持 H2 的方案 |
| 高频请求微服务 | undici | 连接复用是刚需 |
| 浏览器 + Node.js 同构 | 内置 fetch() |
标准 API,跨平台 |
| 需要请求 Mock 测试 | undici | 内置 MockAgent |
💡 **提示:**ofetch 的自动重试和自动 JSON 解析看起来方便,但在生产环境中,你需要更精细的控制(重试哪些状态码、退避策略、连接池大小)。这时候 undici 才是正解。
⚡ 四、性能基准与最佳实践
4.1 性能对比数据
以下是基于 Node.js 22 LTS 的简单基准测试(并发 100 请求,目标为本地 HTTP 服务器):
| 指标 | undici | fetch (内置) | axios | got |
|---|---|---|---|---|
| 请求数/秒 | ~45,000 | ~38,000 | ~22,000 | ~18,000 |
| P50 延迟 | 1.2ms | 1.5ms | 2.8ms | 3.5ms |
| P99 延迟 | 4.5ms | 6.2ms | 12ms | 18ms |
| 内存占用 | 低 | 低 | 中 | 高 |
| 冷启动 | 快 | 快 | 中 | 慢 |
⚡ **关键结论:**在高并发场景下,undici 直接使用比通过 fetch() 包装快约 18%——因为 fetch() 需要将 Web 标准 Request/Response 对象与 undici 内部格式互相转换,这个转换有额外开销。
4.2 生产环境检查清单
在将 HTTP 客户端部署到生产环境前,逐项检查:
- ✅ 超时设置:三层超时全部配置,不要使用默认值
- ✅ 连接池大小:根据目标服务承载能力和并发量设定上限
- ✅ 重试策略:指数退避 + 抖动,只对幂等请求重试
- ✅ 错误分类:区分可重试错误(网络超时、5xx)和不可重试错误(4xx、认证失败)
- ✅ 请求取消:使用
AbortSignal支持请求取消,避免泄漏 - ✅ 优雅关闭:进程退出前关闭 Agent,等待活跃请求完成
- ✅ 指标采集:记录每个请求的状态码、延迟、重试次数
- ✅ Mock 测试:使用 MockAgent 测试异常场景(超时、5xx、网络断开)
// 优雅关闭示例
import { Agent } from 'undici'
const agent = new Agent({
keepAliveTimeout: 10_000,
connections: 100,
})
// 进程退出时关闭连接池
process.on('SIGTERM', async () => {
console.log('Closing HTTP connections...')
await agent.close() // 等待所有活跃请求完成
process.exit(0)
})
🎯 总结
| 需求 | 方案 | 一句话理由 |
|---|---|---|
| 最简单的 HTTP 调用 | 内置 fetch() |
零配置,Node.js 18+ 原生支持 |
| Nuxt/Nitro 全栈项目 | ofetch | 生态集成,开箱即用 |
| 生产级 API 服务 | undici | 连接池、HTTP/2、MockAgent 全家桶 |
| 浏览器 + Node.js 同构 | 内置 fetch() |
Web 标准,一次编写两端运行 |
| 遗留项目维护 | axios | 生态成熟,迁移成本最低 |
2026 年的 Node.js HTTP 客户端选型已经非常清晰:内置 fetch() 解决 80% 的场景,undici 解决剩下 20% 的工程化需求。如果你在用 axios 或 got,不需要急着迁移——但如果是一个新项目,直接从 fetch() 或 undici 开始吧。
⚡ **关键结论:**不要为了"现代化"而盲目迁移 HTTP 客户端。评估你的实际需求:如果 axios 已经能满足你的连接管理、重试和测试需求,那就好好用它。工程化的本质是解决实际问题,不是追逐新工具。
相关工具推荐:
- 🔧 JSON 格式化工具 — 调试 API 响应时格式化 JSON
- 🔧 JSON 验证工具 — 验证 API 返回的 JSON 是否符合预期
- 🔧 Base64 编解码 — 处理 HTTP Basic Auth 的 Base64 编码