动态路由参数

要点

  • 路由参数来自路由器匹配阶段的 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) : param

tryDecodeURIComponent 内部用了 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/0c.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 })
})

如果参数可能为空,处理函数里要做空值判断。正则模式 &#123;.*&#125; 允许空匹配,&#123;.+&#125; 要求至少一个字符。选哪个取决于业务需求。

对已解码的值再做解码

前面提过,但值得再强调一次:

// 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 调用。

延伸阅读

总结

动态路由参数在 Hono 里的数据流可以归纳为三个阶段:

  1. 注册阶段checkOptionalParameter 展开可选参数,getPattern 解析命名正则模式,路由器把所有路由构建成匹配结构
  2. 匹配阶段router.match(method, path) 产出 matchResult,参数值已经按名字映射好,存在 matchResult 里
  3. 读取阶段c.req.param() 从 matchResult 取值,遇到 %XX 序列时做一次自动解码,返回字符串

记住这三层分工,后面遇到参数相关的行为异常(比如取不到值、解码出错、类型不对),就知道该在哪一层排查。

下一篇讲 Query 参数处理——c.req.query()c.req.queries() 的底层实现和性能特征,以及它们与路由参数的边界划分。