Query参数处理

要点

  • c.req.query(key) 返回第一个匹配值,c.req.queries(key) 返回所有匹配值组成的数组
  • Hono 的查询参数解析直接对 URL 字符串做字符级扫描,不构造 URLSearchParams 对象
  • 所有查询参数值都是字符串,数字、布尔值需要手动转换
  • 参数不存在时,query() 返回 undefinedqueries() 也返回 undefined 而非空数组
  • 数组参数(同名 key 出现多次)用 queries() 处理,前端传参时不需要加 [] 后缀
  • @hono/zod-validator 可以把查询参数的类型校验和转换从业务代码里剥离出来
  • 路径参数用于定位资源,查询参数用于筛选、分页、排序——两者的语义边界值得划清楚

1. 基本用法:query() 与 queries()

查询参数挂在 URL 的 ? 后面,用 & 分隔键值对。Hono 提供两个方法来读取它们。

c.req.query(key) 取单个值,返回第一个匹配项:

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
// GET /search?q=hono&page=2
app.get('/search', (c) => {
  const q = c.req.query('q')       // 'hono'
  const page = c.req.query('page') // '2'
 
  return c.json({ q, page })
})
 
export default app

c.req.queries(key) 取同名参数的所有值,返回数组:

// src/index.ts
// GET /filter?tag=js&tag=ts&tag=rust
 
app.get('/filter', (c) => {
  const tags = c.req.queries('tag')
  // ['js', 'ts', 'rust']
 
  return c.json({ tags })
})
 
export default app

两个方法在参数不存在时都返回 undefinedqueries() 返回的不是空数组)。做遍历之前需要先判断存在性。

另外,queries() 在只有一个值时也返回数组(GET /filter?tag=js['js']),不是字符串。这个行为保证了返回值类型一致,调用方不需要根据参数个数做类型分支。

2. 内部实现:字符串扫描而非对象构造

前面 03.05 篇讲 HonoRequest 时提到过,query()queries() 底层调用的是 utils/url.ts 中的 getQueryParamgetQueryParams

标准 Web API 提供了 URLSearchParams 来解析查询字符串,但 Hono 没有走这条路。它直接对 URL 字符串做字符级扫描,按 & 切分键值对,按 = 切分键和值,遇到 % 编码时做一次 decodeURIComponent

这样做的考虑是性能。在高并发场景下,每次请求都创建一个 URLSearchParams 对象、分配内存、做完整解析,开销比字符串扫描要大。Hono 的目标运行时包括 Cloudflare Workers 这类边缘环境,对冷启动和单次请求的内存占用比较敏感。

有一个连带效果值得注意:Hono 对 + 号的处理和 URLSearchParams 不同。URLSearchParams+ 当空格,Hono 不一定做这个转换。如果你的客户端可能发送带 + 号的查询参数,建议在客户端统一用 %20 表示空格,或者在业务层做一次 .replace(/\+/g, ' ')

3. 类型转换

URL 里的所有值都是字符串。?page=2 拿到的 '2' 是字符串,不是数字。?active=true 拿到的 'true' 也是字符串。

数字转换用 Number()parseInt(),但要注意 NaN 的处理:

// src/index.ts
app.get('/posts', (c) => {
  const rawPage = c.req.query('page')
 
  // 没传 page 参数时,rawPage 是 undefined
  // Number(undefined) → NaN
  // parseInt(undefined) → NaN
  const page = rawPage ? Number(rawPage) : 1
 
  if (Number.isNaN(page) || page < 1) {
    return c.json({ error: 'page must be a positive integer' }, 400)
  }
 
  return c.json({ page, limit: 20 })
})

布尔值转换更值得小心。字符串 'false' 在 JavaScript 里是 truthy,直接用 Boolean() 会得到错误结果。正确做法是和预期的字符串值做比较:

// src/index.ts
const rawActive = c.req.query('active')
const active = rawActive === 'true'  // 不要用 Boolean(rawActive)

枚举值转换推荐用显式白名单,或者直接用 Zod(见第 5 节):

// src/index.ts
const VALID_SORTS = ['created_at', 'updated_at', 'title'] as const
 
app.get('/articles', (c) => {
  const sort = c.req.query('sort') ?? 'created_at'
 
  if (!VALID_SORTS.includes(sort as typeof VALID_SORTS[number])) {
    return c.json({ error: `sort must be one of: ${VALID_SORTS.join(', ')}` }, 400)
  }
 
  return c.json({ sort })
})

4. 查询参数 vs 路径参数

01 篇末尾已经点过这两者的区别,这里做一个更系统的对比。

路径参数是 URL 路径的一部分,用来定位资源/users/42 中的 42 是某个具体用户的标识符。

查询参数是 URL 中 ? 后面的部分,用来修饰请求/users?role=admin 中的 role=admin 是对用户列表的筛选条件。

// src/index.ts
// 路径参数 — 定位某一个用户
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id })
})
 
// 查询参数 — 对列表做筛选、分页、排序
app.get('/users', (c) => {
  const role = c.req.query('role')
  const page = c.req.query('page')
  const sort = c.req.query('sort')
  return c.json({ role, page, sort })
})

一个简单的判断标准:如果去掉这个值,URL 指向的资源就变了,那它是路径参数。如果去掉这个值,URL 指向同一类资源、只是返回内容不同,那它适合做查询参数。

/users/42        → 用户 42 的详情页(去掉 42 就不知道是谁了)
/users?role=admin → 用户列表,筛选角色为 admin(去掉 role=admin 仍然是用户列表)

两者的技术差异也值得列一下:

  1. 路由匹配:路径参数参与路由匹配,查询参数不参与。/users/:id 只匹配 /users/42 这类路径,不匹配 /users
  2. 类型约束:路径参数可以在路由定义时用正则做格式约束(:id&#123;\\d+&#125;),查询参数没有这种机制,需要手动校验。
  3. 可选性:查询参数天然可选,不传就是 undefined。路径参数默认必选,要加 ? 标记才能可选(:slug?)。
  4. SEO 影响:路径参数的变化通常代表不同资源页面,查询参数的变化代表同一页面的不同视图。搜索引擎对两者的权重分配不同。

5. Zod 校验

手写类型转换和校验,代码量会随着参数变多而膨胀。@hono/zod-validator 把这些逻辑抽到中间件里,校验通过后的值直接带上正确的类型。

pnpm add @hono/zod-validator zod
// src/index.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
 
const app = new Hono()
 
// 定义查询参数的 schema
const searchQuerySchema = z.object({
  q: z.string().min(1),
  page: z.string().regex(/^\d+$/).transform(Number).default('1'),
  limit: z.string().regex(/^\d+$/).transform(Number).default('20'),
  sort: z.enum(['created_at', 'updated_at', 'title']).default('created_at'),
  order: z.enum(['asc', 'desc']).default('desc'),
})
 
// 用 zValidator 做校验
app.get('/search', zValidator('query', searchQuerySchema), (c) => {
  // 这里的 query 已经通过了校验,类型也转好了
  const query = c.req.valid('query')
 
  // query.page 是 number,不是 string
  // query.sort 是 'created_at' | 'updated_at' | 'title'
  return c.json({
    page: query.page,
    limit: query.limit,
    sort: query.sort,
    order: query.order,
  })
})
 
export default app

zValidator 的第一个参数 'query' 指定校验目标是查询参数。校验失败时,中间件会自动返回 400 响应,不会进入后面的处理函数。

Zod 的 z.coerce.number() 会自动把字符串转成数字,比手动 .transform(Number) 更简洁:

// src/index.ts
const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

6. 边界情况与 URL 编码

空值

?key=      → c.req.query('key') 返回 ''(空字符串)
?key       → c.req.query('key') 返回 ''(空字符串)

空字符串和 undefined 是两回事。如果你的业务逻辑要区分「参数存在但为空」和「参数不存在」,需要做不同的判断:

// src/index.ts
app.get('/test', (c) => {
  const value = c.req.query('key')
 
  if (value === undefined) {
    // 参数完全没传
    return c.json({ status: 'missing' })
  }
 
  if (value === '') {
    // 参数传了,但值为空
    return c.json({ status: 'empty' })
  }
 
  return c.json({ status: 'present', value })
})

特殊字符与 URL 编码

查询参数中的特殊字符需要 URL 编码。Hono 在解析时会自动做 decodeURIComponent

// src/index.ts
// GET /search?q=%E4%BB%8A%E6%97%A5%E6%96%B0%E9%97%BB
// 即 GET /search?q=今日新闻
 
app.get('/search', (c) => {
  const q = c.req.query('q')
  // '今日新闻' — 已自动解码
 
  return c.json({ q })
})

前端构造 URL 时用 encodeURIComponent() 编码参数值就行。需要编码的常见字符包括:空格(%20)、&%26)、=%3D)、+%2B)等。

同名参数

?tag=js&tag=ts

c.req.query('tag') 返回 'js'(第一个值)。 c.req.queries('tag') 返回 ['js', 'ts'](所有值)。

有些前端框架会传 ?tags[]=js&tags[]=ts 这种带方括号的格式。Hono 不会自动把 tags[] 识别成数组——它会把 tags[] 当成一个普通的 key:

// src/index.ts
// GET /filter?tags[]=js&tags[]=ts
 
app.get('/filter', (c) => {
  // 要带方括号取
  const tags = c.req.queries('tags[]')
  // ['js', 'ts']
 
  // c.req.queries('tags') 会返回 undefined
  return c.json({ tags })
})

如果你的前端发送的是带 [] 后缀的格式,后端取值时 key 也要带上 []。或者用 Zod 在中间件层统一处理这个映射。

7. 常见模式:筛选、分页、排序

查询参数最常见的用途就是这三个场景的组合。下面给一个相对完整的例子:

// src/routes/articles.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
 
const app = new Hono()
 
const listQuerySchema = z.object({
  // 分页
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
 
  // 排序
  sort: z.enum(['created_at', 'updated_at', 'title']).default('created_at'),
  order: z.enum(['asc', 'desc']).default('desc'),
 
  // 筛选
  status: z.enum(['draft', 'published', 'archived']).optional(),
  tag: z.string().optional(),
  author: z.string().optional(),
 
  // 关键词搜索
  q: z.string().max(200).optional(),
})
 
app.get('/', zValidator('query', listQuerySchema), (c) => {
  const query = c.req.valid('query')
 
  // 构建查询条件
  const where: Record<string, string> = {}
  if (query.status) where.status = query.status
  if (query.tag) where.tag = query.tag
  if (query.author) where.author = query.author
 
  const result = {
    data: [],       // 实际查询数据库
    pagination: {
      page: query.page,
      limit: query.limit,
    },
    sort: {
      field: query.sort,
      direction: query.order,
    },
    filters: where,
  }
 
  return c.json(result)
})
 
export default app

这个模式的几个设计要点:

  1. 用 Zod 的 .default() 给分页和排序设默认值,业务代码不需要处理缺失参数的情况。
  2. 筛选参数用 .optional(),只有传了才加入查询条件。
  3. 枚举值用 z.enum() 约束,避免非法值透传到数据库查询里。
  4. z.coerce.number() 把字符串自动转成数字,比手动 Number() 再判 NaN 干净。

如果筛选条件很多,可以把分页、排序、筛选拆成独立的 schema,再用 Zod 的 .merge() 合并。

8. 性能与缓存

查询参数的解析发生在首次调用 c.req.query()c.req.queries() 时,不是在请求进入路由就提前解析。如果一个请求的路径里没有调用这两个方法,查询参数的解析开销为零。

Hono 没有对查询参数做缓存。每次调用 c.req.query('key') 都会重新扫描一遍 URL 字符串。这和 bodyCache 的处理方式不同——body 会缓存,query 不会。

对于绝大多数场景,这个设计没有性能问题。URL 字符串通常很短(几百个字符以内),扫描一次的开销在微秒级。如果你在同一个请求处理函数里多次读取同一个查询参数,可以自己存到局部变量里,避免重复扫描:

// src/index.ts
app.get('/data', (c) => {
  const page = c.req.query('page')
 
  // 后续逻辑都用 page 变量,不要反复调用 c.req.query('page')
  const offset = page ? (Number(page) - 1) * 20 : 0
  const cursor = page ? Number(page) : 1
 
  return c.json({ offset, cursor })
})

和路径参数对比:路径参数在路由匹配阶段就已经提取好了,存在 matchResult 里,后续调用 c.req.param() 只是从对象里取值,开销比查询参数的字符串扫描更小。

如果你在做极高吞吐的服务,可以在中间件层统一解析一次查询参数,把结果存到 c.set() 里,后续处理函数直接从 Context 变量中读取。不过这种优化在多数项目里没有太大必要。先用 c.req.query() 写清楚业务逻辑,等性能指标告诉你有瓶颈再做不迟。

延伸阅读

总结

Hono 的查询参数处理围绕两个方法展开:query() 取单个值,queries() 取数组。底层实现直接扫描 URL 字符串,不构造额外对象,解析在首次调用时惰性触发。

所有查询参数值都是字符串。简单的类型转换可以手写,参数多了之后用 @hono/zod-validator 把校验和转换统一收进中间件层,是更可持续的做法。

路径参数和查询参数的分工可以一句话概括:路径参数回答「操作的是哪个资源」,查询参数回答「这次请求想要什么条件、什么范围、怎么排列」。

下一篇讲嵌套路由——当路由层级变深、模块挂载出现多层嵌套时,路径拼接和中间件的作用域会发生什么变化。