Query参数处理
要点
c.req.query(key)返回第一个匹配值,c.req.queries(key)返回所有匹配值组成的数组- Hono 的查询参数解析直接对 URL 字符串做字符级扫描,不构造
URLSearchParams对象 - 所有查询参数值都是字符串,数字、布尔值需要手动转换
- 参数不存在时,
query()返回undefined,queries()也返回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 appc.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两个方法在参数不存在时都返回 undefined(queries() 返回的不是空数组)。做遍历之前需要先判断存在性。
另外,queries() 在只有一个值时也返回数组(GET /filter?tag=js → ['js']),不是字符串。这个行为保证了返回值类型一致,调用方不需要根据参数个数做类型分支。
2. 内部实现:字符串扫描而非对象构造
前面 03.05 篇讲 HonoRequest 时提到过,query() 和 queries() 底层调用的是 utils/url.ts 中的 getQueryParam 和 getQueryParams。
标准 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 仍然是用户列表)
两者的技术差异也值得列一下:
- 路由匹配:路径参数参与路由匹配,查询参数不参与。
/users/:id只匹配/users/42这类路径,不匹配/users。 - 类型约束:路径参数可以在路由定义时用正则做格式约束(
:id{\\d+}),查询参数没有这种机制,需要手动校验。 - 可选性:查询参数天然可选,不传就是
undefined。路径参数默认必选,要加?标记才能可选(:slug?)。 - 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 appzValidator 的第一个参数 '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这个模式的几个设计要点:
- 用 Zod 的
.default()给分页和排序设默认值,业务代码不需要处理缺失参数的情况。 - 筛选参数用
.optional(),只有传了才加入查询条件。 - 枚举值用
z.enum()约束,避免非法值透传到数据库查询里。 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() 写清楚业务逻辑,等性能指标告诉你有瓶颈再做不迟。
延伸阅读
- 03.05-HonoRequest封装机制 — query() 和 queries() 在 HonoRequest 中的定义位置
- 01-基础路由 — 路径参数与查询参数的基本区别
- Zod 官方文档 — Zod 校验库的完整 API
- @hono/zod-validator — Hono 的 Zod 校验中间件
- Hono Request 文档 — HonoRequest 的 API 参考
总结
Hono 的查询参数处理围绕两个方法展开:query() 取单个值,queries() 取数组。底层实现直接扫描 URL 字符串,不构造额外对象,解析在首次调用时惰性触发。
所有查询参数值都是字符串。简单的类型转换可以手写,参数多了之后用 @hono/zod-validator 把校验和转换统一收进中间件层,是更可持续的做法。
路径参数和查询参数的分工可以一句话概括:路径参数回答「操作的是哪个资源」,查询参数回答「这次请求想要什么条件、什么范围、怎么排列」。
下一篇讲嵌套路由——当路由层级变深、模块挂载出现多层嵌套时,路径拼接和中间件的作用域会发生什么变化。