动态路由参数
要点
- 路由参数来自路由器匹配阶段的
matchResult,不是从 URL 用正则临时提取 - Hono 会自动解码
%XX编码序列,不需要也不应该手动再decodeURIComponent - 可选参数
:slug?在注册阶段被展开成多条路由,不是同一条路由内部的分支判断 - 命名正则模式
:name{pattern}让参数能匹配含斜杠的复杂路径段 c.req.param()返回的值始终是字符串,需要自行转换类型- Zod validator 中间件可以把参数校验和类型推导合到一条链路里
- 参数名冲突、尾部斜杠、编码边界是三个高频踩坑点
1. 参数从哪里来
01 里介绍了 :param 语法和 c.req.param() 的基本用法。这篇往下走一层,看看参数在 Hono 内部是怎样传递的。
先看请求到达处理函数之前的完整链路:
// hono-base.js(简化)
#dispatch(request, executionCtx, env, method) {
const path = this.getPath(request, { env })
// 路由器匹配,返回 matchResult
const matchResult = this.router.match(method, path)
// 把 matchResult 传给 Context 构造函数
const c = new Context(request, {
path,
matchResult,
env,
executionCtx,
notFoundHandler: this.#notFoundHandler
})
// ...
}路由器匹配完成后,产出的 matchResult 已经包含了每个命中路由对应的参数映射。c.req.param('id') 做的事只是从这个映射里按 key 取值:
// request.js(简化)
param(key) {
return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams()
}
#getDecodedParam(key) {
// 从 matchResult 中按 routeIndex 和 key 查找
const paramKey = this.#matchResult[0][this.routeIndex][1][key]
const param = this.#getParamValue(paramKey)
// 如果包含 % 编码,做一次解码
return param && /\%/.test(param) ? tryDecodeURIComponent(param) : param
}这个设计带来一个直接的推论:参数的提取发生在路由匹配阶段,不在 param() 调用时。 不管你调一次还是十次 c.req.param('id'),底层都是同一份 matchResult 里的查找操作,不会重复解析 URL。
2. URL 编码与自动解码
当 URL 中包含中文、空格或其他特殊字符时,浏览器和 HTTP 客户端会用 %XX 序列进行编码。Hono 在取参数值时自动完成解码。
// src/index.ts
// GET /search/%E4%BB%8A%E6%97%A5
app.get('/search/:keyword', (c) => {
const keyword = c.req.param('keyword')
// keyword = '今日'(已自动解码)
return c.json({ keyword })
})源码里判断条件写得很克制——只有值中包含 % 才会尝试解码:
// 简化逻辑
return param && /\%/.test(param) ? tryDecodeURIComponent(param) : paramtryDecodeURIComponent 内部用了 try/catch 包裹 decodeURIComponent。如果整体解码失败,它会退回到逐段尝试:
// utils/url.js(简化)
var tryDecode = (str, decoder) => {
try {
return decoder(str)
} catch {
return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => {
try {
return decoder(match)
} catch {
return match
}
})
}
}这意味着:即使 URL 中混入了非法的编码序列(比如 %ZZ),也不会让整个请求崩溃,只是把解码失败的部分原样保留。
这里有一个容易犯的错误——手动再调一次 decodeURIComponent:
// src/index.ts
// GET /search/%E4%BB%8A%E6%97%A5
app.get('/search/:keyword', (c) => {
const keyword = c.req.param('keyword') // '今日'
// ❌ 不要这样做——值已经是解码后的字符串
// const decoded = decodeURIComponent(keyword) // '今日' 碰巧没出错
// 但如果原始值是 '100%',param() 返回 '100%',再 decode 就会抛 URIError
return c.json({ keyword })
})规则只有一条:用了 c.req.param() 就不要再做解码。 Hono 已经处理好了。
3. 多参数组合
一条路由可以声明多个参数段,Hono 会把它们全部放进同一个参数映射里。
// src/index.ts
// GET /orgs/acme/teams/frontend/members/42
app.get('/orgs/:orgId/teams/:teamId/members/:memberId', (c) => {
const { orgId, teamId, memberId } = c.req.param()
// { orgId: 'acme', teamId: 'frontend', memberId: '42' }
return c.json({ orgId, teamId, memberId })
})不传参数调用 c.req.param() 返回包含所有参数的对象。传参数名 c.req.param('orgId') 只返回单个值。
多个参数之间的路径段是严格匹配的。比如注册了 /orgs/:orgId/teams/:teamId,请求 /orgs/acme/channels/frontend 不会命中——teams 这个静态段必须原样出现。
如果需要在参数之间放可变深度的路径,单靠 :param 做不到,需要用到命名正则模式(第 5 节会讲)。
4. 可选参数的注册机制
用 ? 标记可选参数:
// src/index.ts
app.get('/articles/:slug?', (c) => {
const slug = c.req.param('slug')
if (slug) {
return c.json({ article: slug })
}
return c.json({ articles: [] })
})请求 /articles 和 /articles/hello-world 都会命中这条路由。slug 在没有提供时为 undefined。
可选参数在 Hono 内部的处理方式比表面看起来更底层。checkOptionalParameter 函数会在注册阶段把一条带 ? 的路由拆成多条:
// utils/url.js(简化)
// '/articles/:slug?' 被展开为:
// ['/articles', '/articles/:slug']也就是说,Hono 实际上向路由器注册了两条路由:一条是 /articles,一条是 /articles/:slug。匹配时走的是各自独立的路径,不存在「运行时判断参数是否存在」的分支逻辑。
这个机制也支持嵌套的可选参数。比如 /api/:version?/users/:id? 会被展开为 ['/', '/api', '/api/:version', '/api/:version/users', '/api/:version/users/:id'] 五条路由。需要注意,当所有可选参数都不提供时,它等价于根路径 /。这个展开行为是注册时一次性完成的,运行期间不会反复计算。
5. 命名正则模式
:param 默认只匹配到下一个 / 为止。如果需要参数包含斜杠(比如文件路径),就要用命名正则模式 :name{pattern}:
// src/index.ts
// GET /files/docs/2024/readme.md
app.get('/files/:path{.+}', (c) => {
const filePath = c.req.param('path')
// filePath = 'docs/2024/readme.md'
return c.json({ filePath })
}){.+} 里的正则表达式会替代默认的「匹配到下一个 /」行为。.+ 表示匹配一个或多个任意字符(包括斜杠)。
源码中 getPattern 函数处理这种模式:
// utils/url.js(简化)
var getPattern = (label, next) => {
const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/)
if (match) {
if (match[2]) {
// 有自定义正则:生成 RegExp 对象
return [label, match[1], new RegExp(`^${match[2]}$`)]
} else {
// 普通 :param
return [label, match[1], true]
}
}
return null
}常见的正则模式:
// src/index.ts
// 只匹配数字 ID
app.get('/users/:id{\\d+}', (c) => {
const id = c.req.param('id') // 一定是纯数字字符串
return c.json({ id })
})
// 匹配多级文件路径
app.get('/assets/:filepath{.+}', (c) => {
const filepath = c.req.param('filepath')
return c.json({ filepath })
})用正则约束参数格式的好处是:不符合格式的请求会直接走到 404,不需要在处理函数里再写 if 判断。把校验提前到路由层,比在业务代码里做更干净。
6. 参数始终是字符串
不管 URL 里写的是 /users/42 还是 /users/0,c.req.param('id') 返回的都是字符串 '42' 或 '0'。
// src/index.ts
app.get('/users/:id', (c) => {
const id = c.req.param('id')
// ❌ 字符串和数字比较
// if (id === 42) { ... } // 永远不会成立
// ✅ 手动转换
const numId = Number(id)
if (Number.isNaN(numId)) {
return c.json({ error: 'id must be a number' }, 400)
}
return c.json({ id: numId })
})常见的转换方式有三种:Number(rawId) + Number.isNaN 校验(推荐),一元加号 +rawId(简洁但可读性差),parseInt(rawId, 10)(注意要传 radix)。不管用哪种,转换前的校验不能省。
7. 用 Zod 做类型安全的参数校验
手动转换加校验写起来重复。Hono 的 validator 中间件支持对 param 目标做 Zod 校验,把类型推导和运行时校验合到一条链路里。
// src/index.ts
import { z } from 'zod'
import { validator } from 'hono/validator'
const schema = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
})
app.get(
'/users/:id',
validator('param', (value, c) => {
const result = schema.safeParse(value)
if (!result.success) {
return c.json({ error: 'invalid param' }, 400)
}
return result.data
}),
(c) => {
// validator 的返回值可以通过 c.req.valid('param') 拿到
const params = c.req.valid('param')
// params.id 的类型是 number(经过 transform)
return c.json({ id: params.id })
}
)validator 中间件内部取参数的方式就是 c.req.param():
// validator.js(简化)
case 'param':
value = c.req.param()
break校验通过后,结果通过 c.req.addValidatedData('param', res) 存入 Context,后续处理函数用 c.req.valid('param') 取出。这样类型信息可以从 Zod schema 一路传导到处理函数内部,不需要手动 as 断言。
如果项目中大量路由都需要参数校验,可以把 schema 定义和 validator 中间件封装成一个可复用的辅助函数:
// src/middleware/validate-params.ts
import type { Context } from 'hono'
import { validator } from 'hono/validator'
import type { z } from 'zod'
export function validateParams<T extends z.ZodObject<any>>(schema: T) {
return validator('param', (value, c) => {
const result = schema.safeParse(value)
if (!result.success) {
return c.json({ error: 'invalid route params' }, 400)
}
return result.data
})
}
// src/routes/users.ts
import { z } from 'zod'
import { validateParams } from '../middleware/validate-params'
const paramsSchema = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
})
app.get(
'/users/:id',
validateParams(paramsSchema),
(c) => {
const { id } = c.req.valid('param')
return c.json({ id })
}
)这样每个路由文件只需要定义自己的 schema,校验逻辑和类型推导都由 validateParams 统一处理。
8. 容易踩到的坑
参数名冲突
两条路由用了相同的参数名但语义不同,不会报错,但会让代码难以维护:
// src/index.ts
// 能跑,但不建议
app.get('/users/:id', (c) => {
const id = c.req.param('id') // 这里是 userId
return c.json({ id })
})
app.get('/posts/:id', (c) => {
const id = c.req.param('id') // 这里是 postId
return c.json({ id })
})更好的做法是让参数名反映实际含义:
// src/index.ts
app.get('/users/:userId', (c) => {
const userId = c.req.param('userId')
return c.json({ userId })
})
app.get('/posts/:postId', (c) => {
const postId = c.req.param('postId')
return c.json({ postId })
})在嵌套资源里尤其要注意。/users/:userId/posts/:postId 比 /users/:id/posts/:id 更不容易出错——后者虽然 Hono 能正确处理(两个 :id 会被映射到不同的 key 上,取决于哪个路由命中),但读代码的人分不清当前 id 指的是谁。
尾部斜杠
Hono 的路由匹配默认区分尾部斜杠。/users 和 /users/ 是两条不同的路径:
// src/index.ts
app.get('/users', (c) => {
return c.json({ handler: '/users' })
})
// GET /users → 命中上面的路由
// GET /users/ → 404(不会命中)如果需要同时处理两种形式,要么注册两条路由,要么用 getPathNoStrict 的行为(Hono 内部在部分场景会去掉尾部斜杠)。实际项目中,建议统一一种风格——要么全部不以 / 结尾,要么全部以 / 结尾——然后在前端或反向代理层做重定向。
参数值是空字符串
当 URL 中出现连续斜杠时,参数值可能是空字符串:
// src/index.ts
app.get('/files/:path{.+}', (c) => {
const path = c.req.param('path')
// GET /files//readme.md → path = '/readme.md'(注意前导斜杠)
// GET /files/ → 不会命中(.+ 要求至少一个字符)
// GET /files/.+ → 需要换成 .* 才能匹配空值
return c.json({ path })
})如果参数可能为空,处理函数里要做空值判断。正则模式 {.*} 允许空匹配,{.+} 要求至少一个字符。选哪个取决于业务需求。
对已解码的值再做解码
前面提过,但值得再强调一次:
// src/index.ts
app.get('/tags/:name', (c) => {
const name = c.req.param('name') // 已解码
// ❌ 如果 name = '100%',再 decode 会抛异常
// const decoded = decodeURIComponent(name)
// ✅ 直接用
return c.json({ name })
})如果你的日志里偶尔出现 URIError: URI malformed,先排查是不是有多余的 decode 调用。
延伸阅读
- 01-基础路由 —
:param语法和路由注册的基础用法 - 03.05-HonoRequest封装机制 —
param()方法在 HonoRequest 中的完整实现 - 03.02-路由匹配原理 — matchResult 是如何由路由器生成的
- Hono Validator 文档 — validator 中间件的完整用法
- Zod 官方文档 — Zod schema 定义和 transform
总结
动态路由参数在 Hono 里的数据流可以归纳为三个阶段:
- 注册阶段:
checkOptionalParameter展开可选参数,getPattern解析命名正则模式,路由器把所有路由构建成匹配结构 - 匹配阶段:
router.match(method, path)产出matchResult,参数值已经按名字映射好,存在 matchResult 里 - 读取阶段:
c.req.param()从 matchResult 取值,遇到%XX序列时做一次自动解码,返回字符串
记住这三层分工,后面遇到参数相关的行为异常(比如取不到值、解码出错、类型不对),就知道该在哪一层排查。
下一篇讲 Query 参数处理——c.req.query() 和 c.req.queries() 的底层实现和性能特征,以及它们与路由参数的边界划分。