HonoRequest封装机制

要点

  • c.req 是 Hono 对标准 Request 的包装层,c.req.raw 指向原始 Request 对象
  • 路由参数并非从 URL 正则提取,而是来自路由器匹配阶段产出的 matchResult
  • query() / queries() 直接对 URL 字符串做优化解析,不会构造 URLSearchParams
  • Body 读取方法通过 bodyCache 实现缓存,允许以不同格式多次读取同一请求体
  • Header 的大小写不敏感访问由标准 Headers 对象保证,HonoRequest 本身不额外处理
  • parseBody() 是 Hono 独有的便捷方法,将表单数据转成纯对象并支持嵌套键
  • 标准 Request.body 是 ReadableStream,消费后不可回退;Hono 的缓存机制绕过了这个限制

1. HonoRequest 与标准 Request 的关系

上一篇讲了 Context 的整体结构,这篇聚焦 c.req

当你拿到 c.req 的时候,你拿到的不是一个标准 Request,而是 Hono 自己定义的 HonoRequest 类的实例。它在标准 Request 之上封装了一层更便捷的 API。

// src/index.ts
app.get('/debug', (c) => {
  // c.req — HonoRequest 实例
  // c.req.raw — 标准的 Request 对象
 
  console.log(c.req.raw instanceof Request) // true
  console.log(c.req.raw.url)               // 完整 URL
  console.log(c.req.raw.method)            // 'GET'
 
  return c.text('OK')
})

HonoRequest 的构造函数很简洁:

// 简化后的源码
class HonoRequest {
  raw: Request
  path: string
  bodyCache = {}
 
  constructor(request: Request, path = '/', matchResult = [[]]) {
    this.raw = request
    this.path = path
    this.#matchResult = matchResult
  }
}

三个核心输入:原始 Request、当前路径 path、路由匹配结果 matchResult。后续所有便捷方法都围绕这三个数据展开。

c.req.raw 是你回到标准 API 的逃生舱口。当 HonoRequest 没提供你需要的能力时,直接操作 c.req.raw 就行——比如读取 Cloudflare Workers 特有的 cf 属性:

// src/index.ts
app.get('/geo', (c) => {
  const cf = c.req.raw.cf // Cloudflare Workers 特有属性
  return c.json({ country: cf?.country })
})

2. 路径参数的解析

路由参数的来源可能和你想的不一样。

c.req.param('id') 并不是从 URL 里用正则匹配出来的。参数在路由匹配阶段就已经被提取好了,存在 matchResult 里。HonoRequest 只是从匹配结果中取值。

// src/index.ts
// GET /users/42
 
app.get('/users/:id', (c) => {
  const id = c.req.param('id') // '42'
  return c.json({ id })
})

源码层面的逻辑大致是这样:

// 简化后的源码
param(key) {
  return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams()
}
 
#getDecodedParam(key) {
  // 从 matchResult 中查找参数对应的值
  const param = this.#matchResult[0][this.routeIndex][1][key]
  const value = this.#getParamValue(param)
  // 如果包含 % 编码,做一次 decodeURIComponent
  return value && /\%/.test(value) ? tryDecodeURIComponent(value) : value
}

注意两个细节:

  1. 不传参数调用 c.req.param() 会返回所有参数的对象
  2. 值如果包含 URL 编码字符(% 开头的序列),会自动做 decodeURIComponent
// src/index.ts
// GET /search/%E4%BB%8A%E6%97%A5
 
app.get('/search/:keyword', (c) => {
  const all = c.req.param()
  // { keyword: '今日' } — 已自动解码
 
  return c.json(all)
})

如果你手动对参数值再做一次 decode,反而会出错。Hono 已经替你处理过了。

3. 查询参数解析

c.req.query()c.req.queries() 都定义在 HonoRequest 上,底层调用的是 utils/url.ts 中的 getQueryParamgetQueryParams

// 源码
query(key) {
  return getQueryParam(this.url, key)
}
 
queries(key) {
  return getQueryParams(this.url, key)
}

这两个函数直接对 URL 字符串做字符级解析,不会创建 URLSearchParams 对象。对于高频调用的场景,这种字符串扫描方式比构造对象再遍历要快。

query() 返回第一个匹配值:

// src/index.ts
// GET /search?q=hono&page=2
 
app.get('/search', (c) => {
  const q = c.req.query('q')       // 'hono'
  const page = c.req.query('page') // '2'
 
  // 不存在的 key 返回 undefined
  const missing = c.req.query('sort') // undefined
 
  return c.json({ q, page, missing })
})

queries() 返回所有匹配值组成的数组,适用于同名参数出现多次的情况:

// src/index.ts
// GET /filter?tag=js&tag=ts&tag=rust
 
app.get('/filter', (c) => {
  const tags = c.req.queries('tag')
  // ['js', 'ts', 'rust']
 
  // 单个值时也返回数组
  // GET /filter?tag=js → ['js']
 
  // 不存在时返回 undefined
  const empty = c.req.queries('missing') // undefined
 
  return c.json({ tags })
})

这里有个容易混淆的点:queries() 在参数不存在时返回 undefined,不是空数组。如果你要做安全的遍历,需要先判断存在性。

4. Body 读取的各种方法

HonoRequest 提供了五种 body 读取方式:

// src/index.ts
app.post('/data', async (c) => {
  // 1. JSON 解析
  const json = await c.req.json()
 
  // 2. 纯文本
  const text = await c.req.text()
 
  // 3. ArrayBuffer(二进制数据)
  const buffer = await c.req.arrayBuffer()
 
  // 4. Blob
  const blob = await c.req.blob()
 
  // 5. FormData(multipart/form-data 或 application/x-www-form-urlencoded)
  const formData = await c.req.formData()
})

前四个方法直接对应标准 Request 上的同名方法。第五个 parseBody() 是 Hono 独有的:

// src/index.ts
// POST Content-Type: application/x-www-form-urlencoded
// body: name=Alice&age=30
 
app.post('/form', async (c) => {
  const data = await c.req.parseBody()
  // { name: 'Alice', age: '30' }
 
  return c.json(data)
})

parseBody()formData() 的区别在于:formData() 返回标准的 FormData 对象,你需要用 .get('key') 取值;parseBody() 返回纯 JavaScript 对象,可以直接用 data.key 访问。

parseBody() 还支持两个选项:

// src/index.ts
// POST body: tags[]=js&tags[]=ts&user.name=Bob
 
app.post('/complex', async (c) => {
  const data = await c.req.parseBody({ all: true, dot: true })
  // {
  //   tags: ['js', 'ts'],  // all: true 将同名参数合并为数组
  //   user: { name: 'Bob' } // dot: true 将点号键展开为嵌套对象
  // }
  return c.json(data)
})

parseBody() 内部会先检查 Content-Type,只有 multipart/form-dataapplication/x-www-form-urlencoded 才会解析,其他类型直接返回空对象。

5. bodyCache:Body 可重复读取的秘密

04 里提到「请求体通常只能读取一次」——这对标准 Request 是成立的,因为 Request.body 是一个 ReadableStream,消费后就没了。

但 HonoRequest 通过 bodyCache 改变了这个行为:

// 简化后的源码
#cachedBody = (key) => {
  const { bodyCache, raw } = this
 
  // 1. 如果缓存里已经有这个格式的结果,直接返回
  if (bodyCache[key]) {
    return bodyCache[key]
  }
 
  // 2. 如果缓存里有其他格式的结果,基于它做转换
  const anyCachedKey = Object.keys(bodyCache)[0]
  if (anyCachedKey) {
    return bodyCache[anyCachedKey].then((body) => {
      if (anyCachedKey === 'json') {
        body = JSON.stringify(body)
      }
      return new Response(body)[key]()
    })
  }
 
  // 3. 首次读取,从 raw Request 获取并缓存
  return bodyCache[key] = raw[key]()
}

这意味着:

// src/index.ts
app.post('/multi', async (c) => {
  // 先读 JSON
  const data = await c.req.json()
 
  // 再读原始文本——能正常工作,因为 bodyCache 会复用之前的结果
  const raw = await c.req.text()
 
  // 两次读取都拿到正确内容
  return c.json({ parsed: data, rawText: raw })
})

但要注意一个限制:如果你绕过了 HonoRequest,直接用了 c.req.raw.text()c.req.raw.json(),那就不会走 bodyCache,后续再调用 c.req.text() 也会失败——因为底层 stream 已经被你消费了。

// src/index.ts
app.post('/trap', async (c) => {
  // 直接消费了 raw Request 的 body
  const raw = await c.req.raw.text()
 
  // 这里会出错——body 已经被上面消费
  // const data = await c.req.json() // TypeError
 
  return c.text(raw)
})

规则很简单:要么全程用 c.req.json() / c.req.text() 等方法,要么全程用 c.req.raw 的方法,不要混用。

6. Header 访问

c.req.header() 的实现直接委托给标准 Headers 对象:

// 源码
header(name) {
  if (name) {
    return this.raw.headers.get(name) ?? undefined
  }
  // 不传参数,返回所有 header 的普通对象
  const headerData = {}
  this.raw.headers.forEach((value, key) => {
    headerData[key] = value
  })
  return headerData
}

大小写不敏感的特性不是 HonoRequest 实现的,是标准 Headers.get() 本身就支持:

// src/index.ts
app.get('/headers', (c) => {
  // 以下三种写法等价,都能正确获取到值
  const a = c.req.header('Content-Type')
  const b = c.req.header('content-type')
  const cc = c.req.header('CONTENT-TYPE')
 
  // 不传参数,获取全部 headers
  const all = c.req.header()
  // { 'content-type': 'application/json', 'authorization': 'Bearer ...', ... }
 
  return c.json({ a, b, cc })
})

传了不存在的 header 名,返回 undefined(源码里用 ?? undefined 显式处理了 nullundefined 的转换)。

HonoRequest 本身不提供 c.req.cookie() 方法。Cookie 解析通过 hono/cookie 辅助模块提供:

// src/index.ts
import { getCookie, setCookie } from 'hono/cookie'
 
app.get('/dashboard', (c) => {
  // 读取单个 cookie
  const token = getCookie(c, 'session_token')
 
  // 读取所有 cookie
  const all = getCookie(c)
 
  return c.json({ token })
})
 
app.post('/login', async (c) => {
  // 设置 cookie
  setCookie(c, 'session_token', 'abc123', {
    httpOnly: true,
    secure: true,
    maxAge: 86400,
    path: '/',
  })
 
  return c.text('Logged in')
})

getCookie(c, 'name') 内部的实现是解析 c.req.raw.headers.get('Cookie') 字符串,按分号拆分后逐个匹配键值对。它还会做 Cookie 值的合法性校验——名字和值必须符合 RFC 规范才会被解析。

hono/cookie 还提供了签名 Cookie 的能力,用 HMAC-SHA256 防止客户端篡改:

// src/index.ts
import { getSignedCookie, setSignedCookie } from 'hono/cookie'
 
const SECRET = 'my-secret-key'
 
app.get('/verified', async (c) => {
  // 验证签名后返回值,签名无效返回 undefined
  const userId = await getSignedCookie(c, SECRET, 'user_id')
  return c.json({ userId })
})

8. url / path / method 等便捷属性

HonoRequest 上有几个 getter,直接代理到 raw 对象:

// 源码
get url() {
  return this.raw.url
}
 
get method() {
  return this.raw.method
}

path 比较特殊,它不是 getter,而是构造函数里直接赋值的实例属性:

// 源码
class HonoRequest {
  path: string // 构造函数中赋值
 
  constructor(request, path = '/', matchResult = [[]]) {
    this.path = path
  }
}

c.req.pathc.req.url 的区别:

// src/index.ts
// GET http://localhost:8787/users/42?page=1
 
app.get('/users/:id', (c) => {
  c.req.url   // 'http://localhost:8787/users/42?page=1' — 完整 URL
  c.req.path  // '/users/42' — 只有路径部分,不含 query 和 origin
  c.req.method // 'GET'
 
  return c.text('OK')
})

c.req.path 的值在 Context 构造时就确定了,等于请求的实际路径(不是路由模式)。如果你需要获取路由注册时的模式路径(如 /users/:id),可以使用 matchedRoutes 辅助函数从 hono/route 获取。

延伸阅读

总结

HonoRequest 在标准 Request 之上做了三件事:

  1. 参数提取:路由参数从 matchResult 取值并自动 decode,查询参数用字符串扫描优化性能
  2. Body 缓存:通过 bodyCache 让同一次请求可以用不同格式多次读取,前提是全程使用 HonoRequest 的方法而不是直接操作 raw
  3. 便捷封装:path/method/url 等属性简化访问,parseBody() 提供表单到对象的直接转换,cookie 通过辅助模块支持

理解 bodyCache 的机制后,就能避免「body 只能读一次」的常见坑。规则记一条就够:从请求里读数据,始终用 c.req 的方法,不要碰 c.req.raw