next函数执行链
要点
next()的本质是推进中间件链的执行指针,调用它等于把控制权交给下一个处理函数- Hono 用类似 Koa 的 compose 模式把中间件数组组合成嵌套的 Promise 调用链
await next()之前的代码按注册顺序执行,之后的代码按相反顺序执行- 不调用
next()即为短路,响应直接逐层回传,下游中间件和路由处理函数不会执行 - 用 try/catch 包裹
await next()可以统一捕获下游所有层级的异常 - 中间件数量会增加 Promise 跳转次数,但真正的性能瓶颈在中间件内部的 IO 操作
1. next 函数的本质
在 Hono 中,一个中间件的标准签名是:
// src/index.ts
app.use('/api/*', async (c, next) => {
// 前置逻辑
await next()
// 后置逻辑
})这里的 next 是一个异步函数。调用 await next() 做的事情只有一件:执行中间件链中的下一个处理函数,并等待它完成。
这里的「处理函数」可能是另一个中间件,也可能是最终的路由处理函数。中间件和路由处理函数的区别在于——路由处理函数不调用 next(),它直接返回一个 Response。
next 函数的内部实现并不复杂。Hono 在启动请求处理时,会把所有匹配到的中间件和路由处理函数收集到一个数组里,然后维护一个索引。每次调用 next(),索引加 1,取对应的处理函数执行。
2. 执行链的串联方式
中间件数组并不是简单的 for 循环遍历。如果用循环,你无法实现「等下游全部执行完,再回来执行后置逻辑」的效果。
Hono 采用的是 compose 模式——函数组合。核心思路可以用一段简化代码描述:
// 简化示意,非 Hono 源码
function compose(middlewares: Function[]) {
return function (c: Context) {
let index = -1
function dispatch(i: number): Promise<Response | void> {
if (i <= index) {
return Promise.reject(new Error('next() called multiple times'))
}
index = i
const fn = middlewares[i]
if (!fn) return Promise.resolve()
return Promise.resolve(fn(c, () => dispatch(i + 1)))
}
return dispatch(0)
}
}关键点:每个中间件接收的 next 并不是同一个函数,而是 dispatch(i + 1) 的闭包。这意味着:
- 中间件 A 调用
next(),实际执行dispatch(1),即中间件 B - 中间件 B 调用
next(),实际执行dispatch(2),即路由处理函数 - 路由处理函数返回 Response,这个值沿着 Promise 链逐层向上冒泡
如果某个中间件多次调用 next(),i <= index 的检查会直接抛出错误。这就是为什么你只能在中间件里调用一次 next()。
Hono 的真实源码在 hono/src/compose.ts 中,处理了路由匹配、错误边界等额外逻辑,但核心机制就是上面这个——索引推进 + Promise 链。
3. await next() 前后的执行时机
洋葱模型里,await next() 把每个中间件分成「前置」和「后置」两段。请求进来时,前置代码按注册顺序依次执行;响应回去时,后置代码按相反顺序依次执行。
用具体代码说明:
// src/index.ts
const timestamps: string[] = []
app.use('*', async (c, next) => {
timestamps.push('1-前')
await next()
timestamps.push('1-后')
})
app.use('*', async (c, next) => {
timestamps.push('2-前')
await next()
timestamps.push('2-后')
})
app.get('/', (c) => {
timestamps.push('handler')
return c.json({ ok: true })
})请求 GET / 完成后,timestamps 的值是:
['1-前', '2-前', 'handler', '2-后', '1-后']
执行流程可以这样理解:
请求进入
↓
中间件 1 前置 → await next()
↓
中间件 2 前置 → await next()
↓
路由处理函数 → 返回 Response
↓
中间件 2 后置 ← await next() 恢复
↓
中间件 1 后置 ← await next() 恢复
↓
响应返回
await next() 之后的代码之所以能执行,是因为 Promise 的 resolve 机制。下游处理函数返回的 Response 作为 Promise 的值回传给上游,await 表达式拿到这个值后继续执行后续代码。
一个常见的疑问:后置代码能不能修改响应?可以,但有条件。在 Hono 中,路由处理函数返回的 Response 会作为 dispatch() 的返回值。你可以在后置逻辑里读取它,但要替换它需要通过 c.res 重新赋值:
// src/index.ts
app.use('*', async (c, next) => {
await next()
// 给所有响应追加一个 header
c.res.headers.set('X-Processed-By', 'hono')
})4. 短路模式
如果中间件不调用 next(),请求就不会继续往下游传递。响应直接从当前层返回,沿着 Promise 链向上冒泡。这就是「短路」。
// src/index.ts
// 认证中间件
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')
if (!token) {
// 不调用 next(),直接返回
return c.json({ error: 'token required' }, 401)
}
const user = await verifyToken(token)
if (!user) {
return c.json({ error: 'invalid token' }, 403)
}
c.set('user', user)
await next() // 认证成功,继续往下
})当 token 缺失时,return c.json(...) 执行后,后续的中间件和路由处理函数都不会运行。这个 Response 就是最终的响应。
短路在以下场景中被广泛使用:
认证拦截——上面的例子已经展示。验证失败时直接返回错误,不需要走后续流程。
缓存命中——结果已经有了,没必要再查数据库:
// src/index.ts
app.use('/api/*', async (c, next) => {
const cacheKey = c.req.url
const cached = await cache.get(cacheKey)
if (cached) {
return c.json(JSON.parse(cached)) // 短路:缓存命中
}
await next()
// 缓存未命中,下游已经生成了响应,存一份
const res = c.res.clone()
const body = await res.text()
await cache.set(cacheKey, body, { ttl: 60 })
})限流——请求频率超标时拒绝:
// src/index.ts
app.use('*', async (c, next) => {
const key = c.req.header('X-Forwarded-For') ?? 'unknown'
const allowed = await rateLimiter.check(key)
if (!allowed) {
return c.json({ error: 'rate limit exceeded' }, 429)
}
await next()
})需要注意:短路时,await next() 之后的代码不会执行。因为 dispatch(i + 1) 根本没有被调用,Promise 链直接从当前层开始回传。
5. next 之后的错误处理
中间件里 await next() 之后的代码,除了做后置处理,还有一个重要用途——捕获下游抛出的错误。
// src/index.ts
// 全局错误处理中间件(注册在最外层)
app.use('*', async (c, next) => {
try {
await next()
} catch (err) {
console.error(`[${c.req.method}] ${c.req.path}:`, err)
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status)
}
return c.json({ error: 'Internal Server Error' }, 500)
}
})由于 compose 模式的嵌套结构,最外层中间件的 try/catch 能捕获链中任何位置抛出的异常——无论是其他中间件还是路由处理函数。错误会沿着 Promise 链向上冒泡,直到被 catch 住。
这种模式比在每个路由里写 try/catch 干净得多。你只需要在最外层注册一个错误处理中间件,就能兜底所有未处理的异常。
如果错误中间件本身没有捕获异常(比如 catch 块里又 throw 了),Hono 的内部错误处理器会接管,返回一个 500 响应。路由处理函数中 throw new Error(...) 走的也是同一条路径。
一个实用的技巧:可以注册多个错误处理中间件,按不同路径分别处理不同业务的错误:
// src/index.ts
// API 错误处理
app.use('/api/*', async (c, next) => {
try {
await next()
} catch (err) {
return c.json({ error: String(err) }, 500)
}
})
// 页面错误处理
app.use('/pages/*', async (c, next) => {
try {
await next()
} catch (err) {
return c.html('<h1>500 - Server Error</h1>', 500)
}
})6. 执行链的性能考量
每个中间件都会给请求处理增加一定的开销。即使中间件本身不做任何 IO,await next() 也意味着至少两次 Promise 跳转(一次进入下游,一次回来)。
假设有 10 个中间件,一次请求至少要经历 20 次 Promise 跳转。在 V8 引擎上,单次 Promise 跳转大约几十纳秒,20 次跳转的总开销在百纳秒级别。相比之下,一次数据库查询通常是毫秒级——差了四个数量级。
所以结论很直接:中间件数量本身不是性能瓶颈,中间件里做的事情才是。
但有几种情况值得注意:
全局中间件过多。app.use('*', mw) 会让所有请求都经过这个中间件。如果有 5 个全局中间件,即使某些请求只需要其中 1 个,剩下的 4 个也会白跑一遍前置和后置逻辑。
中间件内部有同步阻塞操作。虽然少见,但如果中间件里有大量同步计算(比如复杂的 JSON schema 校验),会直接占用事件循环。
不必要的中间件层。有些中间件可以合并。比如日志和请求 ID 生成完全可以放在同一个中间件里,没必要拆成两个独立的 app.use。
实战建议:
// src/index.ts
// ✅ 把中间件限定到需要的路径
app.use('/api/*', authMiddleware)
app.use('/admin/*', adminMiddleware)
// ❌ 全部挂到全局
app.use('*', authMiddleware)
app.use('*', adminMiddleware)
app.use('*', loggerMiddleware)Hono 的路由匹配做了优化——在 Cloudflare Workers 上使用 Trie 树,在 Deno/Bun 上使用正则。路由匹配本身的开销很低,但中间件链的执行是线性的,没有跳过机制。app.use('*', mw1, mw2, mw3) 会按顺序串行执行,不会自动并行化。
在 Cloudflare Workers 这类对 CPU 时间敏感的环境里(免费版单次请求 CPU 时间上限 10ms),精简中间件数量有实际意义。
7. 与 Koa 中间件的对比
Hono 的中间件模型直接借鉴了 Koa。两者都使用洋葱模型,都依赖 await next() 串联执行链。但实现层面有几处关键差异。
异步机制不同。Koa 最早基于 generator 和 co 库,用 yield next 实现暂停和恢复。现代 Koa(2.x)改用 async/await,但保留了 co 作为兼容层。Hono 从一开始就用纯 async/await,不需要额外的运行时依赖。
Context 挂载方式不同。Koa 用 ctx.state 挂载中间件共享数据,这是一个普通对象。Hono 用 c.set() / c.get() 方法,底层有自己的存储机制。效果类似,API 风格不同。
错误处理约定不同。Koa 依赖最外层中间件的 try/catch,没有内置的错误处理器。Hono 提供了 app.onError() 钩子,可以集中定义错误响应逻辑:
// src/index.ts
app.onError((err, c) => {
console.error(err)
return c.json({ error: 'Something went wrong' }, 500)
})响应生成方式不同。Koa 的中间件不直接返回 Response,而是通过设置 ctx.body 来构造响应。如果最后一个中间件没有设置 ctx.body,Koa 返回 404。Hono 的中间件直接返回 Response 对象,语义更清晰——谁返回的 Response,就是谁响应:
// Koa 风格
app.use(async (ctx) => {
ctx.body = { ok: true } // 通过赋值
})
// Hono 风格
app.get('/', (c) => {
return c.json({ ok: true }) // 通过返回值
})运行时覆盖范围不同。Koa 只能跑在 Node.js 上。Hono 支持 Cloudflare Workers、Deno、Bun、Node.js、AWS Lambda 等多种运行时。这意味着 Hono 的 compose 实现不能依赖 Node.js 特有 API,必须基于 Web Standards。
核心差异总结:Koa 的中间件更灵活(你可以不返回任何东西,靠 ctx.body 拼装响应),Hono 的中间件更明确(必须有返回值,返回的就是 Response)。在团队协作中,Hono 的方式更容易追踪响应来源。
延伸阅读
- Hono 源码 - compose.ts — next 执行链的核心实现
- Koa 官方文档 - Cascading — 洋葱模型的原始设计说明
- 07-中间件洋葱模型 — 上一篇,洋葱模型的整体概念
总结
await next() 做的事情很简单:把控制权交给中间件链的下一个处理函数。但它背后的 compose 模式决定了整个请求处理的执行顺序和错误传播路径。
几个核心结论:
next()本质是索引推进器,每次调用让执行指针移向下一个中间件- 执行链通过 compose 模式串联成 Promise 嵌套结构
await next()前的代码按注册顺序执行,后的代码按相反顺序执行- 不调用
next()就是短路,响应直接回传,下游不执行 - try/catch 包裹
next()可以统一捕获下游所有层级的错误 - 中间件数量的性能影响在于 Promise 跳转次数,但通常不是瓶颈
理解了 next() 的执行链,你就能准确预判中间件的行为顺序,写出正确的认证拦截、错误处理和缓存逻辑。下一篇会深入 Context 对象在中间件链中的传递和修改机制。