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) 做了三件事:

  1. user 对象序列化为 JSON 字符串
  2. 设置 Content-Type: application/json
  3. 用状态码 201 和上面的 body、headers 构造一个标准 Response 对象

Hono 的 Context 上有一组类似的响应方法,它们的职责划分很清楚:

方法用途Content-Type
c.json()返回 JSONapplication/json; charset=UTF-8
c.text()返回纯文本text/plain; charset=UTF-8
c.html()返回 HTMLtext/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:可以是字符串、BlobArrayBufferFormDataURLSearchParamsReadableStream
  • init:一个配置对象,包含 status(状态码)、statusText(状态文本)、headers(响应头)

Hono 的响应方法本质上就是在帮你在调用这个构造器之前,把 bodyinit 准备好。以 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),有两个选择:

  1. 使用 c.body() 手动控制一切
  2. 在返回 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 对象有一个重要特性:一旦构造完成,它的 statusstatusTextheaders 就不可修改。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() 之前调用。

延伸阅读

总结

Hono 的响应生成流程可以概括为一句话:所有响应方法都是 new Response(body, init) 的语法糖。

c.json()c.text()c.html() 各自准备好 body 和 Content-Type,c.status()c.header() 暂存状态码和响应头,最终在响应方法内部一次性合并,传给标准的 Response 构造器。流式响应通过 ReadableStream 把数据生产和 Response 构造解耦,实现边生成边发送。

理解了这个流程,再看 Hono 的响应 API 就不是零散的快捷方法,而是一个统一构造逻辑的不同入口。下一篇会讲中间件机制——请求和响应在中间件链中如何流转。