Response生成流程
要点
- Context 上的响应方法(
c.json/c.text/c.html/c.body/c.redirect)最终都走向同一个内部构造逻辑 - 所有响应方法的终点都是
new Response(body, init)—— Web 标准的 Response 构造器 - 每个响应方法会自动设置对应的
Content-Type,不需要手动写 - 状态码可以通过响应方法的参数传入,也可以用
c.status()提前设定 c.header()可以多次调用,Hono 在内部合并到同一个 Headers 对象- 流式响应通过
c.stream/c.streamText返回ReadableStream作为 body - 标准 Response 对象一旦构造就不可变,所有设置必须在构造之前完成
1. 响应方法的共同模式
上一篇讲了 HonoRequest 对原始 Request 的封装。这篇反过来,看响应是怎么从 Context 上那些方法一步步构造出来的。
先看一个典型的路由处理函数:
app.post('/api/users', async (c) => {
const body = await c.req.json()
const user = { id: crypto.randomUUID(), ...body }
return c.json(user, 201)
})c.json(user, 201) 做了三件事:
- 把
user对象序列化为 JSON 字符串 - 设置
Content-Type: application/json - 用状态码 201 和上面的 body、headers 构造一个标准 Response 对象
Hono 的 Context 上有一组类似的响应方法,它们的职责划分很清楚:
| 方法 | 用途 | Content-Type |
|---|---|---|
c.json() | 返回 JSON | application/json; charset=UTF-8 |
c.text() | 返回纯文本 | text/plain; charset=UTF-8 |
c.html() | 返回 HTML | text/html; charset=UTF-8 |
c.body() | 返回原始数据 | 需要手动设置或为空 |
c.redirect() | 重定向 | 无 body,设置 Location |
这些方法看起来各不相同,但在 Hono 源码里,它们最终都汇聚到同一个内部方法。这个方法的签名大致是:
// 简化表示,非源码原文
respond(body: any, options: { status?: number; headers?: Record<string, string> }): Response所有响应方法不过是这个内部方法的不同「预设」—— 传入不同的 body 和 Content-Type,其余逻辑完全一样。
2. 标准 Response 对象的构造
Web 标准定义了一个 Response 构造器:
new Response(body?, init?)body:可以是字符串、Blob、ArrayBuffer、FormData、URLSearchParams、ReadableStreaminit:一个配置对象,包含status(状态码)、statusText(状态文本)、headers(响应头)
Hono 的响应方法本质上就是在帮你在调用这个构造器之前,把 body 和 init 准备好。以 c.json() 为例,内部流程大致如下:
// 源码逻辑的简化还原
c.json = (data: unknown, status: number = 200) => {
// 1. 序列化
const body = JSON.stringify(data)
// 2. 准备 headers(合并之前通过 c.header() 设置的值)
const headers = new Headers(this._headers)
headers.set('Content-Type', 'application/json; charset=UTF-8')
// 3. 确定状态码(参数优先,否则取 c.status() 设置的值)
const finalStatus = status ?? this._status ?? 200
// 4. 构造标准 Response
return new Response(body, {
status: finalStatus,
headers,
})
}最终返回的就是一个标准的 Response 实例。Hono 框架拿到这个 Response 后直接交给运行环境(Cloudflare Workers、Deno、Node.js 等),不需要任何额外转换。
这也是 04 篇文章里提到的「Hono 处理函数本质上就是接收 Request、返回 Response」这一设计的底层支撑——框架没有发明新的响应类型,而是直接使用了 Web 标准。
3. Content-Type 的自动设置
每个响应方法对应一个默认的 MIME 类型。这个映射关系是硬编码在方法内部的:
// c.json() → application/json; charset=UTF-8
// c.text() → text/plain; charset=UTF-8
// c.html() → text/html; charset=UTF-8自动设置意味着大多数情况下你不需要手动写 Content-Type:
// 不用这样
return c.body(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
// 直接用 c.json() 就够了
return c.json(data)但有一个场景需要注意:如果你在调用响应方法之前已经通过 c.header() 手动设置了 Content-Type,响应方法的自动设置会覆盖它。这个行为的优先级是明确的——响应方法的 Content-Type 是最终决定者。
app.get('/api/data', (c) => {
// 这里手动设置的 Content-Type 会被 c.json() 的默认值覆盖
c.header('Content-Type', 'text/plain')
return c.json({ message: 'hello' })
// 最终 Content-Type 仍然是 application/json
})如果确实需要自定义 Content-Type(比如返回 application/vnd.api+json),有两个选择:
- 使用
c.body()手动控制一切 - 在返回 Response 之后,用
new Response()重新包装
// 方式一:c.body()
return c.body(JSON.stringify(data), {
headers: { 'Content-Type': 'application/vnd.api+json' }
})
// 方式二:先拿到 Response,再包装
const res = c.json(data)
return new Response(res.body, {
status: res.status,
headers: {
...Object.fromEntries(res.headers),
'Content-Type': 'application/vnd.api+json',
},
})4. Status Code 的设置方式
状态码有两种设置路径,最终在构造 Response 时合流。
通过响应方法的参数传入
这是最直接的方式。每个响应方法都接受一个可选的 status 参数:
c.json({ created: true }, 201) // 201 Created
c.text('Not Found', 404) // 404 Not Found
c.redirect('/login', 301) // 301 Moved Permanently如果不传,默认是 200。c.redirect() 比较特殊,默认是 302。
通过 c.status() 提前设置
c.status() 会把状态码暂存在 Context 实例的内部属性上:
app.get('/api/resource', (c) => {
c.status(200) // 暂存,还没有返回 Response
c.header('X-Custom', 'value')
return c.json({ data: 'hello' })
// c.json() 内部没有传 status 参数,就会使用 c.status() 设置的 200
})优先级规则
当两种方式同时存在时,响应方法参数里的值优先:
app.get('/conflict', (c) => {
c.status(404)
return c.json({ error: 'not found' }, 200)
// 最终状态码是 200,不是 404
// 因为 c.json() 的第二个参数明确指定了 200
})这个优先级规则在源码里很简单:响应方法参数传入的值 > c.status() 暂存的值 > 默认值 200。
实际开发中,如果只是返回一个带特定状态码的响应,优先用参数传入的方式,代码更紧凑。只有在状态码的确定和响应体的构造不在同一个位置时(比如中间件里设置状态码),才需要用 c.status()。
5. Headers 的合并逻辑
c.header() 是设置响应头的标准方式。它可以调用多次,每次设置一个或多个头:
app.get('/api/data', (c) => {
c.header('X-Request-Id', crypto.randomUUID())
c.header('Cache-Control', 'no-cache')
c.header('X-Custom', 'value')
return c.json({ data: 'hello' })
})内部存储
Context 内部维护了一个普通的对象(或 Map)来暂存这些 header。每次调用 c.header() 就是往这个容器里写入键值对:
// 简化表示
c.header = (name: string, value: string) => {
this._headers[name] = value
}合并行为
当同一个 header 名被多次设置时,后调用的值会覆盖先前的:
c.header('Cache-Control', 'no-cache')
c.header('Cache-Control', 'max-age=3600')
// 最终 Cache-Control 的值是 max-age=3600这和标准 Headers 对象的 append() 行为不同。标准 Headers.append() 会对同一个 key 追加多个值(用逗号分隔),而 Hono 的 c.header() 是赋值语义。
如果确实需要追加,c.header() 提供了第三个参数:
c.header('Set-Cookie', 'a=1', { append: true })
c.header('Set-Cookie', 'b=2', { append: true })
// 最终会有两个 Set-Cookie 头最终合并时机
暂存的 headers 不会立即生效,而是在响应方法构造 Response 时统一合并:
// 响应方法内部(简化)
const headers = new Headers(this._headers) // 先放入 c.header() 设置的
headers.set('Content-Type', 'application/json') // 再设置方法自带的
return new Response(body, { status, headers })所以你在 c.header() 里设置的值和方法自带的 Content-Type 最终会出现在同一个 Headers 对象里。方法自带的 Content-Type 通过 set() 设置,会覆盖你在 c.header() 里可能写的同名值。
6. 流式响应的生成
前面的响应方法都有一个共同特点:body 在构造 Response 之前就已经完全准备好了。对于大数据集、SSE(Server-Sent Events)或实时推送场景,这不够用——你需要边生成边发送。
Hono 提供了 c.stream() 和 c.streamText() 来处理这种情况。
c.stream()
app.get('/stream', (c) => {
return c.stream(async (stream) => {
// stream 是一个 StreamingApi,封装了 WritableStream
const writer = stream.getWritable()
for (let i = 0; i < 5; i++) {
await writer.write(new TextEncoder().encode(`chunk ${i}\n`))
await stream.sleep(1000)
}
await writer.close()
})
})c.stream() 接受一个回调函数,回调接收一个 stream 对象。你在回调里往里写数据,Hono 在外部把这些数据包装成一个 ReadableStream,作为 Response 的 body:
// 内部逻辑简化
c.stream = (callback) => {
const readable = new ReadableStream({
async start(controller) {
// 把 controller 包装成 StreamingApi 传给回调
await callback(streamingApi)
}
})
return new Response(readable, {
headers: { 'Transfer-Encoding': 'chunked' },
})
}c.streamText()
c.streamText() 是 c.stream() 的特化版本,专门用于文本流,自动设置 Content-Type: text/plain; charset=UTF-8,并且写入时不需要手动 TextEncoder:
app.get('/sse', (c) => {
return c.streamText(async (stream) => {
for (let i = 0; i < 10; i++) {
await stream.write(`data: event ${i}\n\n`)
await stream.sleep(500)
}
})
})与普通响应的区别
普通响应在 return c.json(data) 时,整个 body 已经序列化完毕,Response 构造完成后直接返回给运行环境。
流式响应的 Response 构造完成时,body(ReadableStream)还没有任何数据。数据在回调函数执行过程中逐步写入。运行环境拿到 Response 后开始消费 ReadableStream,每读到一块数据就发送给客户端。
这就是「流式」的含义——Response 对象的构造和数据的生产是解耦的。
7. Response 的不可变性
Web 标准的 Response 对象有一个重要特性:一旦构造完成,它的 status、statusText 和 headers 就不可修改。body 也是一个只能读取一次的流。
const res = new Response('hello', { status: 200 })
res.status = 404 // 无效,status 是只读的
res.headers.set('X', 'y') // 看起来可以,但 Hono 内部不会这样做这个限制对 Hono 的设计有一个直接影响:所有对 status 和 headers 的设置,必须在调用 new Response() 之前完成。
这就是为什么 c.status() 和 c.header() 只是「暂存」操作——它们把值写到 Context 的内部属性上,等到响应方法被调用时,再把这些暂存值一次性传入 new Response() 的构造参数。
// 时间线
c.status(201) // 暂存 status = 201
c.header('X-Custom', 'v') // 暂存 header
return c.json(data)
// ↓ 内部执行:
// new Response(jsonBody, { status: 201, headers: { 'X-Custom': 'v', 'Content-Type': '...' } })
// Response 一旦构造完成,不可再改如果你尝试在拿到 Response 之后再修改,标准 API 不提供这样的能力。这不是 Hono 的限制,而是 Web 标准本身的设计——Response 被设计为不可变的值对象,保证在网络传输过程中的安全性。
实际开发中,这个限制的影响很小。你只需要记住一条规则:c.status() 和 c.header() 要在 return c.xxx() 之前调用。
延伸阅读
- MDN - Response — Web 标准 Response 对象的完整 API 文档
- MDN - ReadableStream — 流式响应的底层 API
- Hono 源码 - context.ts — Context 类的响应方法实现
- 04-Context上下文对象原理 — 上一篇,Context 的基本用法
- 05-HonoRequest封装机制 — 上一篇,请求侧的封装
总结
Hono 的响应生成流程可以概括为一句话:所有响应方法都是 new Response(body, init) 的语法糖。
c.json()、c.text()、c.html() 各自准备好 body 和 Content-Type,c.status() 和 c.header() 暂存状态码和响应头,最终在响应方法内部一次性合并,传给标准的 Response 构造器。流式响应通过 ReadableStream 把数据生产和 Response 构造解耦,实现边生成边发送。
理解了这个流程,再看 Hono 的响应 API 就不是零散的快捷方法,而是一个统一构造逻辑的不同入口。下一篇会讲中间件机制——请求和响应在中间件链中如何流转。