路由注册机制

要点

  • app.get('/path', handler) 在注册阶段就把路径编译成了内部表示,不是存原始字符串等请求来了再遍历
  • 路由表按 HTTP 方法分叉,每种方法各自维护一棵匹配树
  • 路径按 / 切段,用简化版 Trie 树逐级匹配,共享前缀避免重复比对
  • 静态路径、动态参数路径(:param)、通配符路径(*)在树中走不同分支,优先级不同
  • 中间件通过 app.use() 注册,本质也是路由条目,只是匹配模式更宽泛
  • 同类型路由遵循「先注册先匹配」规则,注册顺序影响中间件执行链
  • app.route() 的组合本质是路径拼接后合并路由表,不引入新的匹配层级

1. 注册一个路由时发生了什么

app.get('/users', handler) 时,内部做了两件事:

第一步,Hono 实例上 get 方法接收路径和 handler,直接转发到核心方法 addRoute

// Hono 源码简化
app.get('/users', handler)
// 等价于
app.addRoute('GET', '/users', handler)

app.get()app.post() 这些只是语法糖,真正的入口是 addRoute(method, path, handler)

第二步,addRoute 把路径编译成内部表示,写入路由表。注意是「注册阶段就编译」,不是把原始路径字符串存起来等请求来了再解析。这意味着注册时的路径解析只执行一次,后续所有请求复用编译结果。

注册完成后,这条路由的信息(方法、路径模式、handler 引用)就已经存在于路由树中,等待请求匹配。

2. 路由表的数据结构

Hono 的路由表不是简单的数组,而是按 HTTP 方法分叉的多棵树。每种 HTTP 方法对应独立的路由树:

// 内部结构概念(伪代码)
interface RouterInstance {
  // GET 一棵树,POST 一棵树,各自独立
  match(method: string, path: string): Handler[]
}

请求进来时,先按方法锁定对应的路由树,在那棵树里做路径匹配。这样 GET 请求不会去遍历 POST 的路由,减少无效比对。

路由匹配的核心基于 Trie 树(前缀树)的思想。路径按 / 切段,逐级构建节点:

/users/profile   →  users → profile
/users/settings  →  users → settings

/users/profile/users/settings 共享 users 前缀,从同一个 users 节点分叉。匹配请求时,沿着 users 节点走一步,再按下一段走对应分支,不需要遍历所有注册路径。

Hono 的实际实现比简化 Trie 更复杂——内部有 radix tree 优化,对连续静态段做压缩合并,减少节点数量。同时针对不同的路径模式,Hono 内置了多种路由算法(RegExpRouter、TrieRouter、SmartRouter),默认会根据注册的路由自动选择最优策略。

不管底层选了哪种算法,核心思路一致:按段匹配,共享前缀,逐层深入。

3. 路径模式分类

Hono 的路径分三种模式,在路由树中走不同的分支逻辑。

静态路径

app.get('/users', handler)
app.get('/health', handler)

路径完全固定,请求必须精确匹配。这类路径在内部存储为精确查找表,匹配速度最快——直接查表,不需要逐段遍历。

动态参数路径

app.get('/users/:id', handler)
app.get('/posts/:postId/comments/:commentId', handler)

:param 标记一个变量段,匹配任意非空字符串并提取为参数。在路由树中,:id 节点和普通文本节点是不同的节点类型。匹配时,这个节点能接受任何值,但会把匹配到的内容记录下来,供后续 c.req.param('id') 读取。

多段参数路径就是多个参数节点的串联,每一段独立匹配。

通配符路径

app.get('/api/*', handler)
app.all('/static/*', handler)

* 匹配任意长度的路径片段。通配符路由在匹配时只检查前缀——/api/* 能匹配 /api/users/api/users/42/api/anything/deep/nested

通配符路由的优先级最低。如果一个请求同时能被静态路由和通配符路由匹配到,静态路由优先。

三种模式在路由树中分别维护。匹配时按「静态 → 参数 → 通配符」的顺序尝试。这个优先级是 Hono 内部固定的,不受注册顺序影响。

4. 中间件也是路由

app.use() 注册中间件时,内部调用的也是路由注册逻辑:

app.use('/api/*', async (c, next) => {
  // 中间件逻辑
  await next()
})
 
// 内部等价于
app.addRoute('ALL', '/api/*', middlewareHandler)

中间件和路由的唯一区别是匹配模式:中间件通常用通配符路径(/api/*),匹配一个路径前缀下的所有请求;路由处理函数通常用精确路径(/api/users),只匹配特定路径。

这也解释了一个常见问题——中间件为什么必须放在路由之前注册:

// 正确:中间件在前
app.use('/api/*', authMiddleware)
app.get('/api/users', handler)
 
// 错误:中间件在后,请求 /api/users 时中间件不会被执行
app.get('/api/users', handler)
app.use('/api/*', authMiddleware)

请求到来时,Hono 按路由表中的注册顺序收集匹配的 handler。中间件注册在路由之后,请求匹配到路由时中间件还没被扫描到,自然不会被执行。

不设路径时,中间件对所有路径生效:

app.use(async (c, next) => {
  console.log(`${c.req.method} ${c.req.path}`)
  await next()
})

内部等价于 app.use('/*', handler),匹配所有路径。

5. 路由注册的顺序影响

静态路由、参数路由、通配符路由之间的优先级由 Hono 内部决定,不受注册顺序影响。但同类型路由之间,遵循「先注册先匹配」的规则。

最直接的影响是重复路由:

app.get('/users', (c) => c.text('first'))
app.get('/users', (c) => c.text('second'))
 
// GET /users → 返回 'first',第二个 handler 永远不会被执行

更重要的是中间件的执行顺序。中间件按注册顺序链式执行,先注册的先执行:

// 日志中间件先执行,能记录到所有请求(包括未认证的)
app.use('/api/*', loggerMiddleware)
 
// 认证中间件后执行,日志记录不到认证失败之后的内部逻辑
app.use('/api/*', authMiddleware)
 
app.get('/api/users', handler)

如果反过来注册,认证中间件先执行,认证失败时直接返回 401,日志中间件虽然也执行了,但拿到的上下文可能不同。

一个实用的判断方式:把中间件注册顺序想象成流水线的工序。越靠近入口的工序,看到的原始请求越完整;越靠近路由的工序,越接近最终处理。

6. 子路由与 app.route()

大型项目会把路由按模块拆分。app.route() 负责把子路由挂载到主应用:

const api = new Hono()
 
api.get('/users', (c) => c.json({ users: [] }))
api.get('/posts', (c) => c.json({ posts: [] }))
 
app.route('/api', api)
 
// 最终路径:
// GET /api/users
// GET /api/posts

app.route('/api', api) 内部做的事很直接:把 api 实例中所有已注册路由的路径前缀加上 /api,然后合并到 app 的路由表中。api.get('/users', handler) 变成 app.get('/api/users', handler)

子路由本身也是 Hono 实例,可以独立注册中间件:

const api = new Hono()
 
// 这个中间件只对 /api/* 下的请求生效
api.use('*', authMiddleware)
api.get('/users', handler)
 
app.route('/api', api)

合并后,authMiddleware 的路径从 * 被改写为 /api/*,只拦截 API 路由。

app.route() 支持多层嵌套:

const v1 = new Hono()
const users = new Hono()
 
users.get('/', listHandler)      // → /api/v1/users
users.get('/:id', detailHandler) // → /api/v1/users/:id
 
v1.route('/users', users)
app.route('/api/v1', v1)

每层 route() 都会追加自己的路径前缀。最终路由表里只有扁平的路径条目,没有嵌套结构——合并完成后,Hono 不保留「这个路由来自哪个子应用」的信息。

7. 类型系统在路由注册中的作用

Hono 的 TypeScript 类型不是事后标注,而是参与路由注册过程本身。通过函数重载,每个 app.get() 调用都会在类型层面记录「这个路径用了什么参数」:

app.get('/users/:id', (c) => {
  // TypeScript 知道这里存在 param.id,类型为 string
  const id = c.req.param('id')
 
  // 如果写 c.req.param('name'),编译器会报错
  // 因为路径 /users/:id 里没有 :name 参数
  return c.json({ id })
})

app.route() 挂载子路由时,前缀也会反映到类型中:

const api = new Hono()
api.get('/users/:id', handler) // 子路由内部只知道 /users/:id
 
app.route('/api', api)
 
// 从 app 的角度看,完整路径是 /api/users/:id
// 类型系统追踪了前缀的拼接

类型追踪的意义在于:路径参数、查询参数、中间件注入的变量,都能在编码阶段得到检查。拼错参数名、漏掉必需参数、在中间件之外读取 c.get() 的值——这些错误在编译时就会暴露,不用等到运行时。

延伸阅读

总结

路由注册的本质是「编译路径 + 写入匹配树」。app.get() 调用 addRoute(),后者在注册阶段把路径编译为内部表示,存入按 HTTP 方法分叉的路由树。

路径分三种模式——静态、参数、通配符——在树中走不同分支,匹配时按固定优先级尝试。中间件也是路由条目,只是匹配模式更宽泛。注册顺序影响同类型路由的命中结果和中间件的执行链。app.route() 通过路径拼接和路由表合并实现模块化拆分,不引入额外的匹配层级。TypeScript 通过函数重载在注册阶段追踪路径参数类型,让错误在编译阶段暴露。

理解了路由表的工作方式,就能解释很多「为什么」——为什么中间件要放在路由前面,为什么通配符路由优先级最低,为什么 app.route() 只是路径拼接。

下一篇讲中间件的洋葱模型——请求进来后,多个 handler 如何按注册顺序串联执行。