06-Fetch API工作模型
要点
- Fetch API 由 WHATWG Fetch Standard 定义,整合了 Request、Response、Headers 三个标准对象,在浏览器、Node.js 18+、Deno、Bun、Edge Runtime 中均可用
fetch(url, options)是最基本的调用形式,返回一个 Promise<Response>- Response 的 body 只能读取一次,读完之后 stream 就 consumed 了——这是最常见的「Cannot read body already consumed」错误的来源
response.body返回 ReadableStream,是流式读取 AI 模型响应的基础- AbortController 负责中断正在进行的请求,对 AI 场景中的超时控制和用户取消操作必不可少
- 不同运行时的 Fetch API 在 CORS、超时支持、代理行为上有差异,Hono 的跨平台能力直接依赖 Fetch API 的标准化
1. Fetch API 在 Web Standards 中的位置
在上一篇文章中,我们梳理了 Web Standards 的整体体系——WHATWG 和 W3C 维护的一系列跨平台标准,覆盖了 HTTP 请求处理、URL 解析、流处理、编码、存储等基础能力。Fetch API 是其中对后端开发者最关键的一个标准。
WHATWG Fetch Standard 把三个对象绑定在一起定义:
- Request:描述一次 HTTP 请求的所有信息——URL、method、headers、body
- Response:描述一次 HTTP 响应的所有内容——status、headers、body
- Headers:对 HTTP 头部字段的封装,支持迭代、查找、追加
这三个对象在浏览器里是 window.fetch 的基础,在 Node.js 里是全局 fetch,在 Cloudflare Workers 里是 handler 函数的参数和返回值。它们的 API 在所有运行时中保持一致,这也是为什么 Hono 能够用同一套代码跑在 Node.js、Bun、Edge Runtime 上——它的请求处理完全建立在这三个标准对象之上。
2. 基本用法回顾
最简调用只需要一个 URL:
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
console.log(data.data[0].id)fetch 返回 Promise<Response>,Response 的 .ok 属性在 status 为 2xx 时为 true。注意 fetch 不会自动抛出 HTTP 错误——404、500 都会正常 resolve,你需要手动检查 .ok 或 .status。
也可以显式构造 Request 对象再传给 fetch:
const request = new Request('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '什么是 Fetch API?' }],
}),
})
const response = await fetch(request)这两种写法等价。fetch(url, options) 内部就是用这些 options 构造了一个 Request。
3. Request 对象详解
new Request(url, init) 的 init 参数支持以下选项:
| 选项 | 类型 | 说明 |
|---|---|---|
method | string | HTTP 方法,默认 GET |
headers | HeadersInit | 可以是 Headers 对象、普通对象、[string, string][] |
body | BodyInit | null | 请求体,支持 string、FormData、Blob、ReadableStream 等 |
mode | string | cors、no-cors、same-origin(仅浏览器有效) |
credentials | string | omit、same-origin、include |
cache | string | 缓存策略:default、no-store、reload、no-cache、force-cache、only-if-cached |
redirect | string | follow、error、manual |
signal | AbortSignal | null | 中断信号,后面详述 |
在 AI 后端场景中,最常用的选项是 method、headers、body、signal 这四个。一个典型的 AI API 请求构造:
function buildChatRequest(
apiKey: string,
messages: { role: string; content: string }[],
options?: { stream?: boolean; timeout?: AbortSignal }
) {
return new Request('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages,
stream: options?.stream ?? false,
}),
signal: options?.timeout,
})
}mode、credentials、cache 主要在浏览器环境有意义。在服务端运行时(Node.js、Workers)发起的请求,这些选项大多被忽略。但在 Hono 这种跨平台框架中,请求对象来自运行时,这些字段仍然存在,只是行为不同。
4. Response 对象详解
Response 的核心属性:
status:HTTP 状态码,数字statusText:状态描述,如OK、Not Foundok:status 在 200-299 范围时为trueheaders:Headers 对象body:ReadableStream,用于逐块读取url:响应的最终 URL(经过重定向后)
4.1 Body 读取方法
Response 提供了一组将 body 解析为具体类型的便捷方法:
const response = await fetch('https://api.example.com/data')
// 根据实际数据类型选择对应的方法
const json = await response.json() // 解析为 JSON
const text = await response.text() // 读取为纯文本
const blob = await response.blob() // 读取为 Blob(二进制)
const buffer = await response.arrayBuffer() // 读取为 ArrayBuffer
const form = await response.formData() // 解析为 FormData4.2 Body 只能读取一次
这是 Fetch API 最容易踩的坑:
const response = await fetch('https://api.example.com/data')
console.log(await response.text()) // 第一次读取,正常输出
console.log(await response.text()) // TypeError: body used alreadyResponse 的 body 是一个 ReadableStream,读取操作会 consume 这个 stream。一旦 consume,就不能再读了。如果你需要多次读取同一个 body,有几个做法:
// 方案 1:读取一次,存到变量
const text = await response.text()
const data = JSON.parse(text)
// text 和 data 都可以继续用
// 方案 2:用 clone() 复制一份(在读取之前 clone)
const response1 = response.clone()
const response2 = response.clone()
const json1 = await response1.json()
const text2 = await response2.text()
// 方案 3:tee() 分叉 stream(在读取 body 之前)
const [stream1, stream2] = response.body!.tee()
const json = await new Response(stream1).json()
const text = await new Response(stream2).text()clone() 必须在 body 被读取之前调用。如果 body 已经被 consume 了,clone() 也会报错。
4.3 静态构造方法
在 Hono 中写 handler 时,你会频繁使用 Response 的静态方法来构造返回值:
// 等价于 new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })
return Response.json({ ok: true })
return Response.text('Hello')
return Response.redirect('/login', 302)Hono 的 c.json()、c.text() 底层也是构造这些 Response 对象,只是帮你处理了 headers 和 Content-Type。
5. 流式读取与 ReadableStream
AI 模型 API(OpenAI、Anthropic、Google 等)的流式响应,底层都是 SSE(Server-Sent Events)。服务端返回 Content-Type: text/event-stream,body 是一个持续推送数据的 ReadableStream。
response.body 就是这个 stream。你可以用 getReader() 获取一个 reader,逐块读取数据:
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '用 TypeScript 写一个快排' }],
stream: true,
}),
})
if (!response.ok || !response.body) {
throw new Error(`Request failed: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// value 是 Uint8Array,用 TextDecoder 解码为文本
buffer += decoder.decode(value, { stream: true })
// SSE 协议:每个事件以 \n\n 分隔
// 按行处理缓冲区
const lines = buffer.split('\n')
// 最后一个元素可能是不完整的行,留到下一轮
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const data = trimmed.slice(6) // 去掉 'data: ' 前缀
if (data === '[DONE]') {
// OpenAI 用 [DONE] 标记流结束
break
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content ?? ''
if (content) {
process.stdout.write(content) // 逐块输出
}
} catch {
// JSON 解析失败,可能是跨块的不完整数据,忽略
}
}
}
} finally {
reader.releaseLock()
}这段代码覆盖了流式读取的完整链路:获取 reader → 循环读取 → 解码 → 解析 SSE → 处理业务数据。几个关键细节:
TextDecoder的{ stream: true }选项告诉解码器输入可能是分块的,不要在没有 UTF-8 完整序列时截断lines.pop()把最后一个可能不完整的行留回缓冲区finally块中释放 reader lock,确保资源被清理
5.1 更健壮的 SSE 解析
上面的示例做了简化处理。实际生产中,一个 SSE 事件的内容可能跨多个 chunk,JSON.parse 失败时需要缓冲拼接。更健壮的做法:
function parseSSE(buffer: string): { events: SSEEvent[]; remaining: string } {
const events: SSEEvent[] = []
const chunks = buffer.split('\n\n')
// 最后一块可能不完整
const remaining = chunks.pop() ?? ''
for (const chunk of chunks) {
const fields: Record<string, string> = {}
for (const line of chunk.split('\n')) {
const colonIndex = line.indexOf(':')
if (colonIndex === -1) continue
const field = line.slice(0, colonIndex).trim()
const value = line.slice(colonIndex + 1).trim()
fields[field] = value
}
if (fields.data) {
events.push(fields as SSEEvent)
}
}
return { events, remaining }
}这种按 \n\n 分割的方式符合 SSE 规范,能正确处理事件内容跨块的情况。
6. 请求中断:AbortController
AbortController 是 Fetch API 中用于中断请求的机制。它由两部分组成:
AbortController:控制器,暴露abort()方法AbortSignal:信号对象,通过controller.signal获取,传给fetch的signal选项
const controller = new AbortController()
// 30 秒后自动中断
const timer = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '写一首关于 TypeScript 的诗' }],
}),
signal: controller.signal,
})
clearTimeout(timer)
const data = await response.json()
console.log(data.choices[0].message.content)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('Request was aborted')
} else {
throw error
}
}中断发生时,fetch 的 Promise 会 reject,抛出的错误是 DOMException,name 为 AbortError。
6.1 超时控制
Node.js 的 fetch 从 18 开始支持 AbortSignal.timeout(ms):
const response = await fetch(url, {
// 自动在 10 秒后触发 abort
signal: AbortSignal.timeout(10_000),
})这在 AI API 调用中很实用——模型推理时间不确定,不设超时可能导致请求永远挂起。
如果你需要更精细的控制(比如连接超时 5 秒、读取超时 30 秒),需要自己组合多个信号:
function createAIRequestSignal() {
const controller = new AbortController()
// 连接超时:5 秒
const connectTimeout = AbortSignal.timeout(5_000)
// 如果用户手动取消,转发给 controller
const userCancel = getUserCancelSignal() // 假设从某个来源获取
// 监听所有信号
const signals = [controller.signal, connectTimeout]
if (userCancel) signals.push(userCancel)
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason)
break
}
signal.addEventListener('abort', () => controller.abort(signal.reason), {
once: true,
})
}
return { signal: controller.signal, abort: () => controller.abort() }
}6.2 用户取消流式生成
在 AI 聊天场景中,用户点击「停止生成」按钮需要中断正在进行的流式响应。AbortController 在这里是核心:
// 在 handler 外层持有 controller
let currentController: AbortController | null = null
app.post('/chat/stream', async (c) => {
// 取消上一个请求
currentController?.abort()
currentController = new AbortController()
const body = await c.req.json()
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ ...body, stream: true }),
signal: currentController.signal,
})
// 将上游的流式响应转发给客户端
return new Response(response.body, {
headers: { 'Content-Type': 'text/event-stream' },
})
})
app.post('/chat/stop', (c) => {
currentController?.abort()
currentController = null
return c.json({ ok: true })
})注意这里直接把 response.body(ReadableStream)传给了新的 Response,Hono 会把这个 stream 作为响应体发送给客户端。如果 AbortController 被触发,上游 fetch 中断,stream 结束,客户端的响应也会随之终止。
7. Fetch API 在不同运行时的差异
Fetch API 虽然在各运行时都可用,但行为细节存在差异。这些差异在 Hono 跨平台部署时会实际影响代码行为。
| 特性 | 浏览器 | Node.js 18+ | Deno | Bun | Edge Runtime |
|---|---|---|---|---|---|
| CORS | 强制 | 不限制 | 不限制 | 不限制 | 强制 |
AbortSignal.timeout() | ✅ | 18+ ✅ | ✅ | ✅ | ✅ |
Request.dup() | ❌ | ❌ | ❌ | ❌ | ❌ |
| 自定义协议代理 | 不适用 | 需手动配置 | 不适用 | 不适用 | 不适用 |
fetch() 全局可用 | ✅ | 18+ ✅ | ✅ | ✅ | ✅ |
几个关键点:
CORS 只在浏览器中生效。在 Node.js、Edge Runtime 等服务端环境中,fetch 不受同源策略限制,可以直接请求任意域。但如果你在浏览器中通过 Hono 前端代码调用 fetch,CORS 仍然生效。
AbortSignal.timeout() 在 Node.js 18+ 已经支持。如果你需要兼容 Node.js 16,需要自己用 AbortController + setTimeout 实现。
Edge Runtime(如 Cloudflare Workers、Vercel Edge)完全支持 Fetch API,而且 fetch 是唯一可用的网络请求方式。没有 http.request、没有 axios(除非 polyfill),只有标准的 fetch。这也是为什么 Hono 选择完全基于 Fetch API 构建——它确保代码在 Edge Runtime 上无需任何修改。
Deno 和 Bun 的 fetch 实现与浏览器一致,但在某些边缘行为上可能和 Node.js 有差异。比如错误消息格式、超时精度、stream 的行为等。这些差异在大多数场景下不会暴露,但如果你发现同一段代码在本地 Node.js 跑正常、部署到 Edge Runtime 出问题,fetch 行为差异是第一个排查方向。
这些差异将在下一篇文章中详细展开——对比 Node.js、Bun、Deno 和 Edge Runtime 的运行时特性。
8. Fetch API 与 AI 后端的关系
到这里,你可能觉得 Fetch API 只是「发 HTTP 请求的标准方式」。但在 AI 后端开发中,它的位置比想象中核心。
Hono 的请求处理就是 Fetch API。 Hono handler 接收的 c.req 是对标准 Request 的封装,返回的 c.json() / c.text() 底层构造的是标准 Response。你在 Hono 中写的每一行代码,都在使用 Fetch API 的对象模型。理解 Request、Response、Headers、ReadableStream,就是理解 Hono 的运行基础。
AI SDK 基于 fetch 封装。 无论是 Vercel AI SDK、OpenAI 官方 SDK、Anthropic SDK,底层都是 fetch。它们封装的是参数构造、错误处理、流式解析、重试逻辑,但网络层全是 fetch。当 SDK 的默认行为不满足需求时——比如自定义超时、修改请求头、拦截响应——你需要直接操作 fetch 层。
流式输出是 AI 产品的标配。 用户对 AI 对话的期望是「打字机效果」——一个字一个字地出来,而不是等 10 秒然后一次性输出。这个体验依赖 SSE 流式传输,而服务端对 SSE 的处理就是上一节讲的 ReadableStream 操作。
调试 AI API 问题需要理解 fetch。 当模型返回 429(限流)、400(参数错误)、或者流中途断开时,你需要知道:response.status 告诉你状态码,response.headers 告诉你限流信息(x-ratelimit-remaining),response.body 的 stream 状态告诉你连接是否正常。不理解 Fetch API,这些问题只能对着 SDK 的报错信息猜。
// 一个带完整错误处理的 AI 请求示例
async function callAIWithRetry(
apiKey: string,
messages: { role: string; content: string }[],
maxRetries = 3
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 60_000)
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model: 'gpt-4', messages }),
signal: controller.signal,
})
clearTimeout(timer)
// 限流:等待后重试
if (response.status === 429) {
const retryAfter = response.headers.get('retry-after')
const waitMs = retryAfter ? Number(retryAfter) * 1000 : 2 ** attempt * 1000
await new Promise((r) => setTimeout(r, waitMs))
continue
}
if (!response.ok) {
const errorText = await response.text()
throw new Error(`AI API error ${response.status}: ${errorText}`)
}
const data = await response.json()
return data.choices[0].message.content
} catch (error) {
clearTimeout(timer)
if (error instanceof DOMException && error.name === 'AbortError') {
// 超时,重试
if (attempt < maxRetries - 1) continue
throw new Error('AI request timed out after all retries')
}
// 网络错误,重试
if (attempt < maxRetries - 1) continue
throw error
}
}
throw new Error('Unreachable')
}延伸阅读
- WHATWG Fetch Standard — Fetch API 的规范原文
- MDN: Fetch API — 最常用的参考文档
- MDN: ReadableStream — 流式读取的完整 API
- MDN: AbortController — 请求中断机制
- Cloudflare Workers: Request & Response — Edge Runtime 中 Fetch API 的具体行为
- Node.js Fetch API — Node.js 18+ 的 fetch 实现说明
总结
Fetch API 是 Web Standards 中对后端开发最重要的组成部分。它用 Request、Response、Headers 三个对象统一了所有运行时的网络请求模型,用 ReadableStream 支撑了 AI 流式输出,用 AbortController 解决了超时和取消问题。
在 Hono 生态中,Fetch API 不是「一个可选的网络库」,而是框架的基础。Hono 的 handler、middleware、响应构造全部建立在这套标准之上。掌握 Fetch API 的工作模型——特别是 body 的流式处理、一次性读取限制、中断控制——是理解 Hono 后续章节的前提。
下一篇将对比 Node.js、Bun、Deno 和 Edge Runtime 的差异,帮助你理解 Hono 的同一份代码在不同平台上运行时的行为区别。