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) 的闭包。这意味着:

  1. 中间件 A 调用 next(),实际执行 dispatch(1),即中间件 B
  2. 中间件 B 调用 next(),实际执行 dispatch(2),即路由处理函数
  3. 路由处理函数返回 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 的方式更容易追踪响应来源。

延伸阅读

总结

await next() 做的事情很简单:把控制权交给中间件链的下一个处理函数。但它背后的 compose 模式决定了整个请求处理的执行顺序和错误传播路径。

几个核心结论:

  1. next() 本质是索引推进器,每次调用让执行指针移向下一个中间件
  2. 执行链通过 compose 模式串联成 Promise 嵌套结构
  3. await next() 前的代码按注册顺序执行,后的代码按相反顺序执行
  4. 不调用 next() 就是短路,响应直接回传,下游不执行
  5. try/catch 包裹 next() 可以统一捕获下游所有层级的错误
  6. 中间件数量的性能影响在于 Promise 跳转次数,但通常不是瓶颈

理解了 next() 的执行链,你就能准确预判中间件的行为顺序,写出正确的认证拦截、错误处理和缓存逻辑。下一篇会深入 Context 对象在中间件链中的传递和修改机制。