undici fetch 完整指南:WHATWG Fetch 在 Node.js 中的标准落地实现
undici fetch 完整指南:WHATWG Fetch 在 Node.js 中的标准落地实现
undici 从零实现了 WHATWG Fetch Standard],为 Node.js 提供了与浏览器行为一致的 fetch()、Request、Response、Headers、FormData 全套 API。本文以仓库官方文档 [Fetch.md 为核心骨架,结合 lib/web/fetch 目录下的真实实现源码与 test/fetch 测试用例,系统讲解每个类的构造方式、参数语义、底层原理与安全边界,帮助你在 Node.js 服务端场景中正确、安全地使用这套 API。
fetch():发起一次网络请求
undici 导出的 fetch() 完全遵循 WHATWG Fetch Standard,因此浏览器端的 MDN 文档同样适用于这里。最基本的用法与浏览器一致:
import { fetch, Request, Response, Headers, FormData } from 'undici'
const response = await fetch('https://example.com')
const text = await response.text()
在源码层面,fetch() 的入口定义在 lib/web/fetch/index.js#L160:它会先用 new Request(input, init) 构造请求对象,随后通过 fetching()(lib/web/fetch/index.js#L407)进入完整的 fetching 流程,包括重定向、凭据、缓存模式、CORS 检查等标准步骤。fetch 与 Fetch、fetching、finalizeAndReportTiming 一起从 lib/web/fetch/index.js#L2448 导出,最终由 index-fetch.js 打包对外暴露。
混合实现会抛错
Request、Response、Headers、FormData 这些类是有品牌(brand)检查的,webidl 层的 brandCheck 会校验对象是否来自同一个实现。因此:
- 全局
fetch()搭配全局FormData、Request、Response、Headers; - undici 的
fetch()搭配 undici 导出的同名类。
把一个实现创建的值传给另一个实现的 API 会抛出异常。这一点在 lib/web/fetch/body.js、lib/web/fetch/headers.js 中都能看到对应的 brand check 逻辑。
fetch(input[, init]) 参数详解
fetch() 的第一个参数 input 可以是字符串、URL 或 Request 实例:
- 字符串或
URL直接作为请求目标地址; Request实例则作为请求模板,init中的字段会覆盖模板上的对应字段。
init 是一个 RequestInit 选项对象,完整字段如下表:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
body |
string | Buffer | Uint8Array | Blob | FormData | URLSearchParams | ReadableStream | null |
null |
请求体 |
cache |
string |
— | 缓存模式:'default'、'force-cache'、'no-cache'、'no-store'、'only-if-cached'、'reload' |
credentials |
string |
— | 凭据发送方式:'omit'、'include'、'same-origin' |
dispatcher |
Dispatcher |
全局 dispatcher | 执行请求的分发器,如 Agent、ProxyAgent |
duplex |
string |
— | 请求的 duplex 模式;提供流式 body 时必须为 'half' |
headers |
Headers | Object | Array |
— | 请求头,可为 Headers 实例、普通对象或 [name, value] 数组 |
integrity |
string |
— | 请求的子资源完整性(SRI)元数据 |
keepalive |
boolean |
false |
连接是否可以比创建它的页面活得更久 |
method |
string |
— | 请求方法,如 'GET'、'POST' |
mode |
string |
— | 请求模式:'cors'、'navigate'、'no-cors'、'same-origin' |
redirect |
string |
— | 重定向处理:'error'、'follow'、'manual' |
referrer |
string |
— | 请求 referrer |
referrerPolicy |
string |
— | 参见下方取值列表 |
signal |
AbortSignal | null |
null |
用于中止请求的信号 |
window |
null |
— | 只能为 null,标准保留字段 |
其中 referrerPolicy 的合法取值为:''、'no-referrer'、'no-referrer-when-downgrade'、'origin'、'origin-when-cross-origin'、'same-origin'、'strict-origin'、'strict-origin-when-cross-origin'、'unsafe-url'。
返回值与错误语义
fetch() 返回一个 Promise,在收到响应头时兑现为 Response。需要特别强调的是:
- Promise 只在网络失败时 reject;
- HTTP 错误状态码(如
404、500)仍然会正常兑现,因此必须检查response.ok来判断请求是否真正成功。
import { fetch } from 'undici'
const response = await fetch('https://example.com', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ hello: 'world' }),
})
console.log(response.status)
console.log(await response.json())
从源码看,lib/web/fetch/index.js#L160 起始处的 fetch() 会先处理已中止信号的情况(lib/web/fetch/index.js#L182),随即把请求交给 fetching 流程,最终仅当网络层失败时才 reject Promise。
自定义 dispatcher 路由请求
init.dispatcher 允许你把请求交给自定义 Dispatcher(例如带特定连接选项的 Agent 或 ProxyAgent),这在浏览器 fetch 中没有对应的能力,是 undici 服务端场景的关键扩展点:
import { fetch, Agent } from 'undici'
const response = await fetch('https://example.com', {
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
})
不传 dispatcher 时,实现内部通过 getGlobalDispatcher() 获取全局 dispatcher(见 lib/web/fetch/index.js#L415),可通过 setGlobalDispatcher() 替换。
Class: FormData
FormData 表示一组表单字段键值对,适合作为 fetch() 的请求体,实现同样遵循 WHATWG Fetch Standard,类定义位于 lib/web/fetch/formdata.js#L16。注意同样要遵循"同一实现混用"原则:全局 FormData 配全局 fetch(),undici 的 FormData 配 undici 的 fetch()。
new FormData()
创建一个空的 FormData 实例。传入除 undefined 以外的任何参数都会抛错——特别地,本环境不支持 HTMLFormElement 参数(浏览器里 new FormData(formElement) 的用法在这里不可用)。
字段操作方法
FormData 提供五个核心方法,语义与浏览器完全一致:
| 方法 | 签名 | 说明 |
|---|---|---|
append |
append(name, value[, filename]) |
向已有 key 追加新值;key 不存在则新增。与 set() 不同,保留 name 上已有的其他值 |
delete |
delete(name) |
删除与 name 关联的全部值 |
get |
get(name) |
返回 name 关联的第一个值;没有则返回 null。类型为 string | File | null |
getAll |
getAll(name) |
返回 name 关联的全部值,为 string 与 File 组成的数组 |
has |
has(name) |
返回 boolean,表示 name 是否至少有一个关联值 |
set |
set(name, value[, filename]) |
为已有 key 设置新值并替换旧值;key 不存在则新增 |
其中 value 可为 string 或 Blob;当 value 是 Blob 时,可选的 filename 参数决定上报给服务器的文件名。
Class: Response
Response 表示对一次请求的响应,通常由 await fetch() 获得,也可以直接构造。它继承 BodyMixin,因此拥有下文 Body mixin](#body-mixin) 中的所有读取方法。类定义在 [lib/web/fetch/response.js#L29。
new Response([body[, init]])
| 参数 | 类型 | 默认值 |
|---|---|---|
body |
string | Buffer | Uint8Array | Blob | FormData | URLSearchParams | ReadableStream | null |
null |
init.status |
number |
200 |
init.statusText |
string |
'' |
init.headers |
Headers | Object | Array |
— |
静态方法:Response.error()
返回一个表示网络错误的 Response,其 type 为 'error'。源码实现见 lib/web/fetch/response.js#L36:它通过 makeNetworkError() 构造网络错误响应并以 'immutable' guard 包装。
静态方法:Response.json(data[, init])
把任意 JavaScript 值序列化为 JSON 作为响应体,并自动设置 Content-Type: application/json:
import { Response } from 'undici'
const response = Response.json({ ok: true }, { status: 201 })
init 同样支持 status(默认 200)、statusText(默认 '')与 headers。实现细节在 lib/web/fetch/response.js#L46:先 serializeJavascriptValueToJSONString(data) 序列化,再经 extractBody 提取字节并以 'application/json' 类型初始化响应。对应的参数校验与序列化行为测试见 test/fetch/response-json.js(包括对 Symbol、undefined、抛错 getter 等边界的覆盖)。
静态方法:Response.redirect(url[, status])
创建一条带 Location 头的重定向响应。status 只能是重定向状态码之一:301、302、303、307、308,默认 302。源码中重定向状态集合定义在 lib/web/fetch/constants.js#L8(<a href="https://link.gitcode.com/i/c1490bfe5f315463d7d3ce026b1c5a71" target="_blank">301, 302, 303, 307, 308]),传入其他状态码会抛 RangeError([lib/web/fetch/response.js#L71)。
实例属性
| 属性 | 类型 | 说明 |
|---|---|---|
response.clone() |
方法 | 复制响应;若 body 已被读取或已锁定则抛 TypeError |
response.type |
string |
响应类型:'basic'、'cors'、'default'、'error'、'opaque'、'opaqueredirect' |
response.url |
string |
经重定向后的最终 URL;不可用时为空字符串 |
response.redirected |
boolean |
是否经过一次或多次重定向 |
response.status |
number |
HTTP 状态码 |
response.ok |
boolean |
status 在 200–299 区间内为 true |
response.statusText |
string |
与状态码对应的状态描述文本 |
response.headers |
Headers |
响应关联的 Headers 对象 |
Class: Request
Request 表示一次资源请求,实例可以直接传给 fetch() 代替 URL 字符串。类定义在 lib/web/fetch/request.js#L89,同样继承 BodyMixin。
new Request(input[, init])
input 可以是 URL 字符串、URL 实例或另一个 Request(作为复制模板)。init 的字段与 fetch() 的 init 完全一致,传入后会覆盖模板上的对应字段。参数校验测试见 test/fetch/request.js 开头对各类非法入参(缺参、非法类型、非法 URL 等)的覆盖。
实例属性一览
| 属性 | 类型 | 说明 |
|---|---|---|
request.clone() |
方法 | 复制请求;body 已被读取或锁定则抛 TypeError |
request.method |
string |
请求方法,如 'GET' |
request.url |
string |
序列化后的请求 URL |
request.headers |
Headers |
请求关联的 Headers 对象 |
request.destination |
string |
请求目标类型,如 ''、'image'、'script' |
request.referrer |
string |
请求 referrer,可能为 'about:client' 或 URL 字符串 |
request.referrerPolicy |
string |
请求的 referrer policy |
request.mode |
string |
'cors'、'navigate'、'no-cors'、'same-origin' |
request.credentials |
string |
'omit'、'include'、'same-origin' |
request.cache |
string |
请求的缓存模式 |
request.redirect |
string |
'error'、'follow'、'manual' |
request.integrity |
string |
子资源完整性元数据 |
request.keepalive |
boolean |
请求是否可以比创建它的环境活得更久 |
request.isReloadNavigation |
boolean |
是否为刷新导航 |
request.isHistoryNavigation |
boolean |
是否为历史导航 |
request.signal |
AbortSignal |
关联的中止信号 |
request.duplex |
string |
恒为 'half' |
Class: Headers
Headers 表示请求或响应的头列表,并提供读写方法,实现位于 lib/web/fetch/headers.js#L429。它是可迭代对象,迭代时按名称排序产出 [name, value] 对。
new Headers([init])
init 可以是 Headers 实例、普通对象,或 [name, value] 二元组数组。
实例方法
| 方法 | 签名 | 说明 |
|---|---|---|
append |
append(name, value) |
追加一个值;header 不存在则新建,保留已有值 |
delete |
delete(name) |
删除指定 header |
get |
get(name) |
返回合并后的值;不存在返回 null |
has |
has(name) |
返回 boolean |
set |
set(name, value) |
设为单一值,替换已有值 |
getSetCookie |
getSetCookie() |
返回所有 Set-Cookie 头的值数组,不合并,每个 Set-Cookie 单独成串 |
getSetCookie() 是浏览器 Headers 上近年新增的能力,undici 中实现于 lib/web/fetch/headers.js#L613:它读取内部头列表中单独维护的 cookies 数组并按序返回,从而避免多个 Set-Cookie 被逗号合并导致语义丢失。对应行为测试见 test/fetch/headers.js#L679 与 test/fetch/cookies.js#L114。
Body mixin:读取请求/响应体
Request 与 Response 都继承 BodyMixin(实现在 lib/web/fetch/body.js)。每个消费方法都只读取 body 一次:读完一次后 bodyUsed 变为 true,再调用其他消费方法会抛 TypeError。
[!WARNING]
arrayBuffer()、blob()、bytes()、formData()、json()、text()这六个方法会在返回前把整个 body 缓冲进内存,并在适用时解码/解析负载且长期保留该表示。调用它们等于信任 body 足够小、能放进可用内存。不要用它们处理来自不可信或用户可控来源的 body;应改用body.body或body.textStream()增量处理,并施加应用自定义的大小上限。
消费方法总览
| 方法 | 返回 | 说明 |
|---|---|---|
arrayBuffer() |
Promise<ArrayBuffer> |
包含 body 字节的 ArrayBuffer |
blob() |
Promise<Blob> |
包含 body 的 Blob |
bytes() |
Promise<Uint8Array> |
包含 body 字节的 Uint8Array |
formData() |
Promise<FormData> |
将整个 body 按 multipart/form-data 或 application/x-www-form-urlencoded 解析(已废弃,见下) |
json() |
Promise<any> |
将 body 解析为 JSON |
text() |
Promise<string> |
以 UTF-8 解码 body |
textStream() |
ReadableStream<string> |
undici 扩展:以 UTF-8 解码的文本块流,而非整体缓冲(实验性,不属于 WHATWG Fetch Standard) |
body |
ReadableStream | null |
body 原始流;消息无 body 时为 null |
bodyUsed |
boolean |
body 被读取后为 true |
bytes() 与 textStream() 的实现细节可在 lib/web/fetch/body.js#L396 与 lib/web/fetch/body.js#L405 找到:前者基于 consumeBody 把字节序列包装成 Uint8Array;后者在 body 不可用时抛 TypeError,并把解码后的文本块推入一个 ReadableStream 供消费。
formData() 的废弃与替代方案
formData() 标记为 Stability: 0 - Deprecated:它整体缓冲 body 并做 multipart 解析,而 multipart 解析存在固有安全风险,因此只能在来自可信服务器的响应上调用。对于不可信或用户可控的服务器,应使用专门的流式解析器(如 @fastify/busboy)并施加应用级限制:
import { Busboy } from '@fastify/busboy'
import { Readable } from 'node:stream'
const response = await fetch('...')
const busboy = new Busboy({
headers: { 'content-type': response.headers.get('content-type') },
})
// Handle the events emitted by `busboy`.
Readable.fromWeb(response.body).pipe(busboy)
处理不可信 body 的推荐姿势
面对用户可控或不可信的响应体,正确做法是流式消费 + 限流:
- 通过
response.body(原始ReadableStream)或response.textStream()(文本块流)增量读取; - 应用自定义大小上限,超过即中止或丢弃;
- 避免一次性缓冲类方法(
json()、text()等)。
这正是 lib/web/fetch/body.js 中消费方法与流式路径并存的设计意图:前者服务于可信小负载的便捷解析,后者服务于不可信负载的安全处理。
从源码看实现边界
几个值得注意的底层细节,帮助理解这套 API 的行为边界:
- 无 body 状态码:
101、204、205、304被定义为nullBodyStatus(lib/web/fetch/constants.js#L6),这些状态码对应的响应不允许携带 body; - 重定向状态码:
301、302、303、307、308构成重定向集合(lib/web/fetch/constants.js#L8),Response.redirect()与redirect: 'follow'均依赖此集合; - 坏端口拦截:WHATWG 标准规定的一批坏端口(
1、7、9、21、22、23、25等)在 fetching 流程中被显式阻止(lib/web/fetch/constants.js#L14); - 入口封装:index-fetch.js 对
fetchImpl做了 Promise 包装,并在 catch 中为错误附加当前模块的调用栈,方便在 Node.js 内联打包场景下定位问题。
总结
undici 的 fetch 家族 API 是浏览器 Fetch Standard 在 Node.js 服务端的完整落地:fetch() 负责发起请求并只对网络失败 reject,Request/Response 承载请求与响应语义并共享 BodyMixin 的消费能力,Headers 提供迭代与 Set-Cookie 安全读取,FormData 提供标准表单编码。实际编码时记住三条主线:同实现对象不可混用、HTTP 错误状态需靠 response.ok 判断、不可信 body 务必走流式路径并自行限流。需要深入钻研实现时,可依次阅读 lib/web/fetch/index.js(fetch 流程)、lib/web/fetch/body.js(Body mixin)、lib/web/fetch/response.js(Response 构造与静态方法),并对照 test/fetch 下的用例验证行为。