Koa 是 Express 原班人马打造的"下一代"框架。它把框架本身砍到极小(连路由、body 解析都不内置),核心只留下一样东西:基于 async/await 的**洋葱模型(Onion Model)**中间件。
如果说 Express 的中间件是"流水线",那 Koa 的中间件就是"洋葱" —— 这篇讲清这两者的本质区别。
先看洋葱模型长什么样
直接上代码,感受执行顺序:
import Koa from 'koa'
const app = new Koa()
app.use(async (ctx, next) => {
console.log('1 进入第一层')
await next()
console.log('6 离开第一层')
})
app.use(async (ctx, next) => {
console.log('2 进入第二层')
await next()
console.log('5 离开第二层')
})
app.use(async (ctx, next) => {
console.log('3 进入第三层')
ctx.body = 'hello'
console.log('4 处理完成')
})
app.listen(3000)一个请求进来,输出是:
1 进入第一层
2 进入第二层
3 进入第三层
4 处理完成
5 离开第二层
6 离开第一层看出来了吗?执行路径像一根针穿过洋葱再穿回来:await next() 之前的代码按注册顺序正序执行(进入),await next() 之后的代码按逆序执行(离开)。这就是"先进后出"。
┌───────────────────────────┐
│ 1 ────────────────► 6 │ 第一层
│ ┌───────────────────┐ │
│ │ 2 ──────────► 5 │ │ 第二层
│ │ ┌───────────┐ │ │
───► │ │ │ 3 ──► 4 │ │ │ ───► 最内层
│ │ └───────────┘ │ │
│ └───────────────────┘ │
└───────────────────────────┘和 Express 到底差在哪
Express 的 next() 是个普通函数调用,调完就调完了,它不"等待"下游执行结束。所以 Express 里你很难在"所有后续中间件都跑完之后"再做点什么。
Koa 的 next() 返回一个 Promise,配合 await,当前中间件会暂停在 await next() 这一行,等里面所有中间件(以及它们的 await next())全部执行完,再回来执行后半段。
这个差异带来一个杀手级能力:优雅地包裹整个请求。比如统计响应耗时,在 Express 里要 hack res 的事件,在 Koa 里天然就能写:
// 响应耗时统计中间件,放在最外层
app.use(async (ctx, next) => {
const start = Date.now()
await next() // 等内层全部处理完
const ms = Date.now() - start
ctx.set('X-Response-Time', `${ms}ms`)
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})错误处理也一样优雅,一个 try/catch 兜住所有下游:
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.status || 500
ctx.body = { message: err.message }
// Koa 里 async 抛错能被外层 catch 到,这是 Express 4 做不到的
}
})注意最后这点:Koa 因为全程基于 Promise,异步错误能自然地被外层 try/catch 捕获,不像 Express 4 那样需要手动 next(err)。这是 async/await 架构带来的红利。
ctx:把 req 和 res 合二为一
Express 给你 req 和 res 两个对象,Koa 把它们封装进一个 ctx(Context) 对象,并挂上一堆便捷属性:
app.use(async (ctx) => {
// 请求相关
ctx.method // GET
ctx.url // /users?id=1
ctx.query // { id: '1' }
ctx.headers // 请求头
// 响应相关:赋值即响应,不用 res.send()
ctx.status = 200
ctx.body = { ok: true } // 自动序列化成 JSON 并设好 Content-Type
// ctx.request / ctx.response 是 Koa 封装的对象
// ctx.req / ctx.res 是 Node 原生对象,需要时可下钻
})最舒服的是 ctx.body —— 你只管赋值,Koa 自动帮你判断是字符串、Buffer、Stream 还是对象,设好对应的 Content-Type 和 Content-Length。
洋葱模型的核心:compose
洋葱模型怎么实现的?答案就是 Koa 依赖的 koa-compose。它把中间件数组"组合"成一个函数,核心逻辑非常精炼:
function compose(middleware) {
return function (ctx, next) {
let index = -1
function dispatch(i) {
// 防止一个中间件里多次调用 next()
if (i <= index) {
return Promise.reject(new Error('next() called multiple times'))
}
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
// 关键:把 dispatch(i+1) 作为 next 传给当前中间件
// 当前中间件 await 的就是"下一个中间件的完整 Promise"
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}精髓在这一行:fn(ctx, dispatch.bind(null, i + 1))。
每个中间件拿到的 next,其实就是"调用下一个中间件的 dispatch"。当你 await next() 时,等的是下一个中间件返回的 Promise,而那个 Promise 又包着它自己的 await next()…… 一层层 Promise 嵌套,自然形成了"进去再出来"的洋葱结构。
一个常见错误:忘了 await
新手最容易犯的错:写了 next() 但忘了 await。
// ❌ 错误:没 await,洋葱"回来"的时机就乱了
app.use(async (ctx, next) => {
const start = Date.now()
next() // 忘了 await!
// 下游还没跑完,这里的耗时统计就是错的
ctx.set('X-Response-Time', `${Date.now() - start}ms`)
})少了 await,当前中间件不会等下游执行完,后半段会"提前"运行,耗时统计、错误捕获全部失效。Koa 中间件里调 next() 几乎总要配 await。
小结
- Koa 的中间件是洋葱模型:
await next()前正序"进入",之后逆序"离开",先进后出 - 和 Express 的本质区别:Koa 的
next()返回 Promise,能await等下游全部跑完,从而优雅地包裹整个请求(耗时统计、统一错误处理) - async 错误能被外层 try/catch 自然捕获,不像 Express 4 要手动
next(err) ctx把 req/res 合一,ctx.body赋值即响应- 洋葱靠
compose实现:把dispatch(i+1)当next传下去,Promise 层层嵌套 - 中间件里调
next()务必await,否则洋葱"回来"的时机会错乱
Koa 用更少的代码、更现代的 async/await,把中间件这件事做得比 Express 更优雅。理解了洋葱模型,你再看 Nest、Egg 这些基于 Koa 思想的框架会更通透。