第一次看到下面这段代码时,很容易产生一个疑问:
serve({
fetch: app.fetch,
port: 3000,
})如果 app 已经是一个 Hono 应用,为什么还需要 serve()?Hono 自己不是 HTTP Server 吗?
答案是:Hono 的核心不是一个负责监听端口的服务器,而是一套请求处理框架。它接收标准 Request,返回标准 Response:
app.fetch(request: Request): Response | Promise<Response>至于请求如何从 Node.js、AWS Lambda、Cloudflare Workers 或 Bun 到达这里,由运行时或 Adapter 负责。
这个边界是理解 Hono 的起点。
把一次请求拆成五层
Hono 的核心可以压缩成五层:
Web Standard HTTP 抽象
+ Router 匹配
+ Context 状态容器
+ Middleware compose 调度
+ TypeScript 类型传播它们解决的是不同问题。
| 层次 | 负责什么 | 不负责什么 |
|---|---|---|
| Web Standard HTTP | 用 Request / Response 统一请求模型 | 不监听端口 |
| Router | 根据 method 和 path 找出匹配项 | 不执行 handler |
| Context | 保存一次请求中的共享状态 | 不决定执行顺序 |
| compose | 调度 middleware 和 handler | 不做路由匹配 |
| TypeScript 类型 | 传播参数、环境和响应类型 | 不校验外部真实数据 |
完整链路可以写成:
运行时 Server 或 Event Runtime
-> Adapter
-> Web Standard Request
-> app.fetch()
-> Router.match()
-> Context
-> compose(matched handlers)
-> Web Standard Response
-> Adapter 写回运行时框架的复杂度并没有消失,而是被分配到了边界清晰的组件中。
Adapter 为什么存在
以 Node.js 为例,原生 HTTP Server 使用的是:
IncomingMessage
ServerResponseHono 使用的是:
Request
Response@hono/node-server 的核心职责就是在两组接口之间翻译:
IncomingMessage
-> Web Request
-> app.fetch()
-> Web Response
-> ServerResponse在原生支持 Web Fetch API 的运行时中,这一层可能很薄;在 AWS Lambda 这类事件运行时中,Adapter 还要完成 Event 与 Request、Response 与 Lambda Result 的双向转换。
这带来一个重要结果:Hono 核心不需要理解每个平台的请求对象。增加运行时支持时,变化主要停留在边界层。
Router 只回答“谁匹配”
注册代码:
app.use('*', logger())
app.use('/api/*', auth())
app.get('/api/users/:id', handler)不会立即执行这些函数。Hono 先把它们注册进 Router。请求到达后,Router 根据 method 和 path 找出匹配项,并保持注册顺序。
Hono 默认使用 SmartRouter。它在第一次真正匹配时,优先尝试性能更高的 RegExpRouter;如果路由表达超出其支持范围,再退到更通用的 TrieRouter。
这个选择只发生在 matcher 建立阶段,不是每次请求都重新判断。代价是 matcher 建立后,不再适合继续动态追加路由。
因此默认 Router 适合路由集合在服务启动时已经确定的普通应用;插件系统、运行时动态挂载路由等场景,需要更明确地考虑 TrieRouter 或自己的生命周期约束。
Middleware 和 route handler 本质上是同一种函数
在运行时,middleware 和 route handler 都可以看成:
type Handler = (
context: Context,
next: () => Promise<void>
) => Response | void | Promise<Response | void>差异主要来自职责:
- middleware 通常调用
await next(),把控制权交给下一层。 - route handler 通常返回
Response,结束请求。 - middleware 也可以不调用
next(),直接返回 401 或其他响应。
Router 得到匹配项后,compose() 会把它们组合成一条执行链。简化后的核心是:
function compose(handlers) {
return function run(context) {
let index = -1
return dispatch(0)
async function dispatch(i) {
if (i <= index) {
throw new Error('next() called multiple times')
}
index = i
const handler = handlers[i]
if (!handler) return
return handler(context, () => dispatch(i + 1))
}
}
}next 不是关键字。它只是 compose() 在运行时创建并传给当前 handler 的闭包:
() => dispatch(i + 1)洋葱模型不是额外魔法
假设执行链为 A、B、handler:
const A = async (c, next) => {
console.log('A before')
await next()
console.log('A after')
}
const B = async (c, next) => {
console.log('B before')
await next()
console.log('B after')
}输出会是:
A before
B before
handler
B after
A after所谓洋葱模型,本质上是普通函数调用栈和 await:
- A 在
await next()处暂停。 - B 在自己的
await next()处暂停。 - handler 返回。
- B 继续执行。
- A 继续执行。
理解这一点之后,中间件顺序、提前返回、错误传播和重复调用 next() 的问题都会变得具体。
Context 是一次请求的共享容器
Router 负责找出匹配项,compose 负责执行,Context 则承载一次请求中的共享状态,例如:
- 当前请求和路径参数。
- 环境变量与平台绑定。
- 校验后的数据。
- 中间件写入的业务状态。
- 最终响应与错误状态。
它把 middleware 之间需要共享的数据放在请求生命周期内,而不是塞进全局变量。
应用级路由表和 handler 通常长期存在;Context、dispatch 索引和 next 闭包只属于一次请求。请求结束且没有其他异步任务继续引用它们后,这些状态就可以被回收。
类型安全有两个层次
Hono 的 TypeScript 类型系统可以把路径参数、环境变量和响应类型传播到后续代码,但客户端提交的数据在运行时仍然只是外部输入。
因此类型安全需要两层配合:
Validator / Schema
-> 在运行时证明输入满足约束
TypeScript 类型传播
-> 让校验结果在后续代码中可用只写泛型不能阻止客户端发送错误数据;只做运行时校验而不传播类型,又会损失开发体验。
为什么这个模型值得理解
Hono 的价值不只在于它小或快,而在于它把 Web 框架的核心边界暴露得足够清楚:
Adapter 统一运行时差异
Router 决定谁匹配
Context 保存请求状态
compose 决定如何执行
类型系统约束编译期关系
Validator 检查运行时输入更重量级的框架也在处理相似问题,只是它们还叠加了容器、注解、反射、AOP 和更多基础设施。
先通过 Hono 看清一条请求如何从运行时进入框架、经过匹配和中间件、最终返回响应,再去理解其他 Web 框架,会更容易分辨哪些是通用结构,哪些只是特定生态的实现方式。