跳到主要内容

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

也就是典型的“先进后出”。

根因有两个:

  1. 上游中间件会在 await next() 处暂停。
  2. 下游全部执行完后,Promise resolve,上游从暂停点恢复继续往后执行。

先看洋葱模型的执行图

如果换成口述版:

  • 进入 m1,先执行前置逻辑。
  • m1await 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 句解释:

  1. middlewares 本质是一个函数数组。
  2. dispatch(i) 负责执行第 i 个中间件。
  3. 当前中间件拿到的 next,其实是 () => dispatch(i + 1)
  4. Promise.resolve(...) 保证同步中间件和异步中间件都能被统一串起来。
  5. 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 会自动倒着执行”,而是:

  • m1await 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() 只能调用一次。