koa 洋葱模型怎么实现的:为什么能“先进后出”?
面试速答(30 秒版 TL;DR)
- Koa 洋葱模型的本质不是“概念图”,而是 中间件链 +
next()递归派发 + Promise 串联。 - 每个中间件都像一个
async (ctx, next) => {}函数;调用await next()之前是“向内走”,await next()之后是“向外退”。 - 之所以看起来像洋葱一层层进入再一层层退出,是因为 Koa 会把中间件数组
compose成一个大 Promise 链。 next()不是“继续执行下一个函数”这么简单,它返回的是“下游所有中间件执行完成后的 Promise”。- 面试一句话:Koa 洋葱模型的关键不是
async/await本身,而是dispatch(i)递归调用下一个中间件,并把返回值包装成 Promise,让上游能在下游结束后继续执行。
心智模型:一条线性的数组,被组合成可回退的调用链
假设中间件是这样:
app.use(m1);
app.use(m2);
app.use(m3);
执行顺序并不是简单的 m1 -> m2 -> m3 结束,而是:
m1 start
m2 start
m3 start
m3 end
m2 end
m1 end
也就是典型的“先进后出”。
根因有两个:
- 上游中间件会在
await next()处暂停。 - 下游全部执行完后,Promise resolve,上游从暂停点恢复继续往后执行。
先看洋葱模型的执行图
如果换成口述版:
- 进入
m1,先执行前置逻辑。 m1调await next(),进入m2。m2再调await next(),进入m3。m3没有更下游了,执行结束并 resolve。m2恢复执行后置逻辑。m1最后恢复执行后置逻辑。
Koa 核心实现思路:compose
Koa 的经典实现思想可以压缩成下面这段:
function compose(middlewares) {
return function (ctx, next) {
let index = -1;
function dispatch(i) {
if (i <= index) {
return Promise.reject(new Error("next() called multiple times"));
}
index = i;
let fn = middlewares[i];
if (i === middlewares.length) {
fn = next;
}
if (!fn) {
return Promise.resolve();
}
try {
return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
} catch (error) {
return Promise.reject(error);
}
}
return dispatch(0);
};
}
面试时建议你按这 5 句解释:
middlewares本质是一个函数数组。dispatch(i)负责执行第i个中间件。- 当前中间件拿到的
next,其实是() => dispatch(i + 1)。 Promise.resolve(...)保证同步中间件和异步中间件都能被统一串起来。await next()等到的是“后面整条链执行完”的结果,所以才能写前后置逻辑。
为什么 await next() 后面的代码会最后执行
看一个最小例子:
app.use(async (ctx, next) => {
console.log("m1 before");
await next();
console.log("m1 after");
});
app.use(async (ctx, next) => {
console.log("m2 before");
await next();
console.log("m2 after");
});
app.use(async (ctx, next) => {
console.log("m3");
});
输出:
m1 before
m2 before
m3
m2 after
m1 after
原因不是“JS 会自动倒着执行”,而是:
m1在await next()处把执行权交给下游;m2也同理;m3执行完成后返回一个已完成 Promise;- Promise 链逐层 resolve,所以上游依次恢复。
为什么 Koa 必须强调“next() 只能调用一次”
这是 koa-compose 的经典保护逻辑:
if (i <= index) {
return Promise.reject(new Error("next() called multiple times"));
}
因为一旦同一个中间件里多次调用 next(),整条调用链就会被破坏。
比如:
app.use(async (ctx, next) => {
await next();
await next();
});
问题在于:
- 下游链路会被重复进入;
- 响应、副作用、日志、数据库写入都可能重复;
- 整个“先进后出”的调用结构不再成立。
所以面试里可以直接说:
- Koa 洋葱模型成立的前提之一,就是每层只把控制权往下传一次。
Koa 为什么比 Express 更容易写“前后置逻辑”
关键不是 Koa 比 Express 多了什么神秘机制,而是它把中间件接口设计成了:
async (ctx, next) => {}
这让你很自然就能写:
app.use(async (ctx, next) => {
const start = Date.now();
try {
await next();
} finally {
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} ${ms}ms`);
}
});
这个模式特别适合:
- 统一日志
- 统一错误处理
- 事务包裹
- 权限校验
- 响应包装
因为它天然支持“进入时做一件事,出去时再做一件事”。
典型题 & 标准答法
Q1:Koa 洋葱模型的本质是什么?
- 本质是中间件组合函数
compose。 - 每个中间件拿到的
next都是“调用下一个中间件”的函数。 await next()等到下游完成后再恢复,所以形成先进后出的结构。
Q2:为什么说 next() 返回的是 Promise?
- 因为 Koa 要统一同步和异步中间件。
- 只有把下游执行结果 Promise 化,上游才能
await next()并在下游结束后继续执行。
Q3:为什么 next() 不能调两次?
- 会导致同一段下游链路被重复执行。
- 这会破坏中间件调用顺序和副作用边界。
- 所以
koa-compose内部会用索引做保护。
Q4:Koa 洋葱模型最典型的使用场景是什么?
- 请求日志
- 统一异常捕获
- 统一返回体封装
- 鉴权和权限校验
- 数据库事务或资源清理
易错点 / 坑
- 把洋葱模型只解释成“先进去再出来”,却说不清是怎么实现的。
- 以为
next()是立即同步调用后面函数,忽略了 Promise 串联。 - 忘记
await next()后面的代码一定是“下游完成后”才恢复。 - 不知道
next()调用多次会出错。 - 把 Koa 洋葱模型和 Express 的
next()完全等同,这会在深问时露出漏洞。
速记要点(可背诵)
- Koa 洋葱模型 = 中间件数组 +
dispatch(i)递归 + Promise 链。 next本质是() => dispatch(i + 1)。await next()前是进入下游,await next()后是回到上游。next()返回的是下游整条链执行完成后的 Promise。- 一层中间件里
next()只能调用一次。