中间件执行顺序
要点
- 中间件按注册顺序执行,先
app.use()的先接触请求 - 洋葱模型把每个中间件切成前后两半,
await next()是分界点 - CORS 必须在鉴权之前,否则 OPTIONS 预检请求会被 401 拦截
- 日志放在最外层,确保被拦截的请求也有记录
- 请求 ID 紧跟日志,后续中间件和路由可以读取
- 顺序错误是中间件调试里最常见的问题,往往要抓包才能看清
1. 洋葱模型回顾
上一节已经出现过洋葱模型。这里把重点放在「执行顺序」本身。
多个中间件注册后,请求穿过它们的顺序是:
请求 → A 前段 → B 前段 → C 前段 → 路由处理 → C 后段 → B 后段 → A 后段 → 响应
await next() 之前的代码叫「前段」,之后的代码叫「后段」。前段按注册顺序执行,后段按注册的相反顺序执行。
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.use('*', async (c, next) => {
console.log('A - 前')
await next()
console.log('A - 后')
})
app.use('*', async (c, next) => {
console.log('B - 前')
await next()
console.log('B - 后')
})
app.use('*', async (c, next) => {
console.log('C - 前')
await next()
console.log('C - 后')
})
app.get('/', (c) => {
console.log('handler')
return c.text('ok')
})
export default app输出:
// output.txt
A - 前
B - 前
C - 前
handler
C - 后
B - 后
A - 后这个执行模型决定了中间件能做两类事:
- 前段做的事:在请求到达路由之前检查、修改或拦截。鉴权、参数校验、请求 ID 注入都在这里
- 后段做的事:在路由返回后处理响应。修改响应头、记录耗时、写入日志都在这里
2. 推荐的全局中间件顺序
一个经过验证的推荐顺序:
app.use('*', logger())
app.use('*', requestId())
app.use('*', cors())
app.use('*', secureHeaders())
app.use('*', timing())
// 后续才是业务中间件(鉴权、限流等)每个位置的理由:
2.1 logger 在最外层
app.use('*', logger()) // 1
app.use('*', cors()) // 2如果 CORS 中间件拦截了 OPTIONS 预检请求并返回,logger 仍然能记录这次请求。把 logger 放后面,被拦截的请求就不会出现在日志里,排查问题时缺少线索。
2.2 requestId 紧随 logger
app.use('*', logger()) // 1
app.use('*', requestId()) // 2请求 ID 是后续中间件和路由追踪用的。越早生成,能被越多模块引用。logger 放在它前面,是为了让日志也能带上 requestId(如果 logger 支持从 context 读取的话)。
2.3 CORS 在鉴权之前
app.use('*', cors()) // 3
app.use('/api/*', auth) // 4浏览器跨域请求会先发 OPTIONS 预检请求,预检请求不带 Authorization 头。如果 auth 在 CORS 之前,预检请求会被 401,浏览器拿不到 CORS 响应头,真正的请求不会发出。
这个顺序错误调试起来非常隐蔽,因为 Postman 或 curl 不发预检请求,问题只在浏览器环境出现。
2.4 secureHeaders 放在 CORS 之后
app.use('*', cors()) // 3
app.use('*', secureHeaders()) // 4secureHeaders 会设置 CSP、X-Frame-Options 等响应头。放在 CORS 之后,能保证这些安全头在所有响应上生效(包括 CORS 预检的响应)。
2.5 timing 包住整个链路
app.use('*', secureHeaders()) // 4
app.use('*', timing()) // 5
// 业务中间件 ...timing 统计的是从注册点到路由返回、再到 timing 后段的耗时。放得越靠外,统计到的耗时越完整。如果放在鉴权之后,被鉴权拦截的请求不会被 timing 统计到。
3. 顺序错误的常见案例
3.1 鉴权在 CORS 之前
// ❌ 错误顺序
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
await next()
})
app.use('*', cors())浏览器跨域请求流程:
- 浏览器发 OPTIONS 预检(不带 Authorization)
- 鉴权中间件返回 401
- 浏览器拿不到
Access-Control-Allow-Origin头 - 浏览器拒绝发送真正的请求
- 前端报 CORS 错误
正确顺序:
// ✅ 正确顺序
app.use('*', cors())
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
await next()
})OPTIONS 请求由 CORS 中间件直接响应,不会到达鉴权中间件。
3.2 日志在 CORS 之后
// ❌ 日志位置靠后
app.use('*', cors())
app.use('*', logger())如果 CORS 中间件处理 OPTIONS 后直接返回,logger 的后段仍然会执行(因为 OPTIONS 是 CORS 中间件的响应阶段)。但如果 CORS 的实现在前段就 return 了,logger 的后段不会被执行。
把 logger 放在最外层,可以确保所有请求(无论被谁拦截)的前段都被记录。
3.3 secureHeaders 在路由之后
// ❌ 安全头位置靠后
app.use('/api/*', auth)
app.get('/api/users', (c) => c.json([]))
app.use('*', secureHeaders()) // 永远不会执行app.use() 注册的中间件只对注册之后的路由生效。如果 secureHeaders() 写在路由声明之后,它不会影响之前声明的路由。
正确做法:所有 app.use() 放在路由声明之前。
4. 中间件提前返回
中间件可以不调用 await next(),直接返回响应。此时后续中间件和路由处理函数都不会执行:
app.use('*', async (c, next) => {
console.log('A - 前')
// 不调用 next(),直接返回
return c.json({ error: 'blocked' }, 403)
// 以下代码不会执行
console.log('A - 后')
})
app.use('*', async (c, next) => {
console.log('B - 前')
await next()
console.log('B - 后')
})
app.get('/', (c) => {
console.log('handler')
return c.text('ok')
})输出:
// output.txt
A - 前B 的前段、handler、B 的后段、A 的后段都不会执行。
这种机制用于鉴权失败、限流触发等场景。需要注意:提前返回的中间件之后的所有中间件都不会运行,包括 logger 的后段、timing 的后段等。如果 logger 在这个中间件之前注册,logger 的前段已经执行过了,日志仍然会被记录。
5. 前缀中间件和内联中间件的顺序组合
全局中间件、前缀中间件、内联中间件同时存在时,执行顺序是:
请求
→ 全局中间件(按注册顺序)
→ 前缀中间件(按注册顺序)
→ 内联中间件(按书写顺序)
→ 路由处理函数
← 内联中间件(逆序)
← 前缀中间件(逆序)
← 全局中间件(逆序)
响应
app.use('*', async (c, next) => {
console.log('G1')
await next()
console.log('G1 - end')
})
app.use('*', async (c, next) => {
console.log('G2')
await next()
console.log('G2 - end')
})
app.use('/api/*', async (c, next) => {
console.log('P1')
await next()
console.log('P1 - end')
})
const inline1 = async (c, next) => {
console.log('I1')
await next()
console.log('I1 - end')
}
const inline2 = async (c, next) => {
console.log('I2')
await next()
console.log('I2 - end')
}
app.get('/api/data', inline1, inline2, (c) => {
console.log('handler')
return c.text('ok')
})访问 /api/data 的输出:
// output.txt
G1
G2
P1
I1
I2
handler
I2 - end
I1 - end
P1 - end
G2 - end
G1 - end每一层按注册或书写顺序进入,按相反顺序退出。
6. 子应用挂载时的顺序
主应用和子应用各有自己的中间件栈。请求穿过它们的顺序是:
请求
→ 主应用全局中间件
→ 主应用前缀中间件
→ 子应用全局中间件
→ 子应用前缀中间件
→ 子应用路由
← 子应用前缀中间件
← 子应用全局中间件
← 主应用前缀中间件
← 主应用全局中间件
响应
// src/apps/api.ts
import { Hono } from 'hono'
const api = new Hono()
api.use('*', async (c, next) => {
console.log('sub-global')
await next()
console.log('sub-global - end')
})
api.get('/users', (c) => {
console.log('sub-handler')
return c.json([])
})
export default api// src/index.ts
import { Hono } from 'hono'
import api from './apps/api'
const app = new Hono()
app.use('*', async (c, next) => {
console.log('main-global')
await next()
console.log('main-global - end')
})
app.route('/api', api)
export default app访问 /api/users 的输出:
// output.txt
main-global
sub-global
sub-handler
sub-global - end
main-global - end主应用的中间件先于子应用执行。子应用内部的中间件只在子路由的范围内生效。
7. 排查顺序问题的方法
中间件顺序问题通常不会报类型错误,也不会在启动时暴露。排查方式:
7.1 添加临时日志
在每个中间件的前段和后段加 console.log,看实际执行顺序是否符合预期。
7.2 抓包看响应头
浏览器 DevTools 的 Network 面板可以看到响应头。CORS 头缺失、安全头缺失、Server-Timing 缺失等问题,都可以通过响应头判断。
7.3 检查被拦截的请求
如果一个 POST 请求在浏览器里报 CORS 错误,但 Postman 里正常,大概率是鉴权中间件在 CORS 之前拦截了 OPTIONS 请求。
7.4 单元测试执行顺序
用测试验证中间件的执行顺序,避免回归:
it('中间件按注册顺序执行', async () => {
const app = new Hono()
const order: string[] = []
app.use('*', async (c, next) => {
order.push('A-before')
await next()
order.push('A-after')
})
app.use('*', async (c, next) => {
order.push('B-before')
await next()
order.push('B-after')
})
app.get('/', (c) => c.text('ok'))
await app.request('/')
expect(order).toEqual([
'A-before',
'B-before',
'B-after',
'A-after',
])
})总结
中间件执行顺序是 Hono 中间件体系里最容易出错、也最容易排查的部分。
这一节涉及到的几个关键判断:
- 洋葱模型:
await next()把中间件切成前后两半,前段按注册顺序执行,后段按相反顺序执行 - 推荐顺序:
logger → requestId → cors → secureHeaders → timing,业务中间件放在这些公共能力之后 - CORS 在鉴权之前:避免 OPTIONS 预检被拦截导致跨域失败
- 日志在最外层:确保被拦截的请求也有记录
- 提前返回的影响:中间件不调用
next()时,后续所有中间件和路由都不会执行 - 排查方法:临时日志、抓包看响应头、单元测试执行顺序
顺序问题不会在编译期暴露,只会在运行时以「跨域失败」「日志缺失」「响应头缺失」等形式出现。理解洋葱模型的执行规律,是预防这类问题的基础。
下一篇看自定义中间件开发——从内联函数到 createMiddleware(),怎样写出类型安全、可复用的中间件。