Koa 和 Express 有什么区别?中间件洋葱模型是怎样实现的?
Express 在 Node 的 req、res 上扩展方法,自带路由、静态文件等功能;Koa 由 Express 原班团队设计,核心只有中间件机制,用 async 函数和 ctx 对象,路由等功能交给第三方模块。Koa 中间件 await next() 之后还会回到自己继续执行,形成先进后出的洋葱模型,靠 compose 递归调用 dispatch 实现。
简答
写一个统计接口耗时的中间件:在 Koa 里,先记下开始时间,
await next()等后面的中间件和路由全部处理完,再回到这里算出耗时、写进响应头。请求像穿过洋葱一样一层层进去,再一层层出来,这就是洋葱模型。Koa 和 Express 的区别主要在定位。Express 在 Node 原生的
req、res上加了很多方法,自带路由、模板、发送文件等功能,是一个完整的应用框架。Koa 是 Express 原班团队设计的,核心只有中间件机制,不内置任何中间件,也不带路由,把请求和响应封装进一个ctx对象,其余功能交给第三方模块。洋葱模型是靠 compose 函数实现的。它把中间件数组变成一个函数,
dispatch(i)执行第 i 个中间件,并把() => dispatch(i + 1)作为next传进去,每次调用都返回 Promise。所以await next()会等后面所有中间件执行完,再回到当前中间件继续往下走。要注意,Koa 中间件里调用
next()时一定要await或return,否则后面的中间件还没执行完,当前中间件就已经结束了。Express 和 Koa 的中间件签名也不一样,不能直接混用。
两个框架的定位有什么不同
Koa 的官方文档有一篇专门对比两者,原话是 Koa 想“修复并替代 Node”,Express 则是“增强 Node”。具体体现在三点。
第一是请求和响应对象。Express 直接在 Node 的 req、res 上添加属性和方法,比如 res.json()、req.params。Koa 不暴露原生对象给业务代码,而是提供自己的 ctx.request、ctx.response,设置响应体写 ctx.body = ...。
第二是内置功能。对比表里,Express 自带路由、模板、发送文件、JSONP,Koa 这些都没有,文档说明这不代表 Koa 做不到,只是交给 koa-router、@koa/router 这类第三方模块实现。所以 Koa 本身很小,搭一个项目要自己挑选中间件。
第三是异步写法。Koa 的中间件就是 async 函数,错误用 try/catch 捕获。Express 传统的中间件形式是 (req, res, next),文档也说明这种形式和 Koa 不兼容,从 Express 迁移到 Koa 基本要重写。
洋葱模型是什么样的
Koa 文档里的例子是这样写的:
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
app.use(async (ctx) => {
ctx.body = 'Hello World';
});
第一个中间件执行到 await next() 时暂停,控制权交给下一个中间件,文档称为“向下游”;后面没有中间件了,就开始回退,每个中间件从 await next() 之后继续执行,文档称为“向上游”。所以计时中间件能拿到包括后续所有处理在内的总耗时。
手写一个简化版 compose
下面是一个简化的 compose,用 .then 观察结果。重点看执行顺序和 next 被调用两次时会发生什么:
function compose(middleware) {
return function (ctx) {
let index = -1;
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() 被调用了多次'));
index = i;
const fn = middleware[i];
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}
const logs = [];
const run = compose([
async (ctx, next) => {
logs.push('A 进入');
await next();
logs.push('A 离开');
},
async (ctx, next) => {
logs.push('B 进入');
await next();
logs.push('B 离开');
},
async (ctx) => {
logs.push('C 处理');
ctx.body = 'Hello';
},
]);
const ctx = {};
run(ctx).then(() => {
console.log(logs.join(' → ')); // A 进入 → B 进入 → C 处理 → B 离开 → A 离开
console.log(ctx.body); // Hello
});
const twice = compose([
async (ctx, next) => {
await next();
await next();
},
]);
twice({}).catch((err) => console.log(err.message)); // next() 被调用了多次
为什么 B 离开会排在 C 处理之后?因为 B 里的 await next() 等的是 dispatch(2) 返回的 Promise,这个 Promise 要等 C 执行完才完成。A 等的是 dispatch(1),而 dispatch(1) 又要等 B 整个执行完,所以 A 离开排在最后。
index 用来防止同一个中间件调用两次 next。第一次调用后 index 已经是 1,第二次再调用 dispatch(1) 时 i <= index 成立,直接返回失败的 Promise。为什么要拦住?因为后面的中间件和路由会再执行一遍,可能写两次响应。
try/catch 是为了处理同步抛错的中间件,把它也转成失败的 Promise,外层统一用 .catch 或 try/catch 处理。当前的 koa-compose 源码改成了 async 函数写法,同样会在 next() 被调用多次时抛出错误,开发环境下还会检查中间件是否在下游完成之前就结束了,提示“可能漏了 await 或 return”。
Express 的中间件怎样传递
Express 的中间件也按注册顺序执行,当前中间件不结束响应时,必须调用 next() 把控制权交给下一个,否则请求会一直挂着。错误处理中间件靠参数个数区分,必须写满四个参数 (err, req, res, next)。
异步错误方面,Express 5 的文档说明,返回 Promise 的路由处理函数和中间件在 Promise 失败或抛错时,会自动调用 next(错误),所以 async 函数里的错误不需要额外处理就能到达错误处理中间件。Express 4 没有这个行为,async 函数里的错误要自己 catch 后调用 next(err)。
常见追问
Express 能实现洋葱模型吗?
能做到一部分。Express 的
next()不返回等待后续处理完成的 Promise,所以不能像 Koa 那样写await next()然后接着处理响应。这正是 Koa 用 async 函数和 Promise 改写中间件流程的原因。
Koa 里错误怎样统一处理?
在最外层注册一个中间件,用
try { await next() } catch (err) { ... }包住,后面任何中间件抛出的错误或失败的 Promise 都会沿着洋葱回到这里,统一设置状态码和响应体。
新项目选 Koa 还是 Express?
想要开箱即用、生态里现成的中间件多,选 Express;想要核心小、自己组合中间件、习惯 async 写法,选 Koa。两者的中间件不能直接混用,选定后最好不要中途切换。