实现
一、认识
在 Koa
中,中间件 其实就是一个函数,通常采用 async
函数形式,接收两个参数:ctx
(上下文对象)和 next
(下一个中间件的执行函数)。这种函数签名大致如下:
async function middleware(ctx, next) {
// 前置逻辑
await next(); // 调用下一个中间件,形成“洋葱模型”
// 后置逻辑
}
Koa
的中间件 执行遵循 洋葱模型: 请求依次进入中间件链(前置逻辑),在最内层的中间件结束后,再依次执行中间件链的后置逻辑, 中间件通过 await next()
串联起来,形成一个基于 Promise
的调用链, 通过决定是否调用 next()
,中间件可以实现请求的拦截、提前结束请求或者在调用下一个中间件后执行后续操作, 所有中间件共享同一个 ctx 对象,可以在其中附加自定义属性或方法,实现跨中间件的数据传递。这种设计允许在中间件中进行请求前和请求后的处理,非常适合用于日志记录、错误处理、权限验证等场景。
Koa
中间件原理: 定义递归函数 递归函数 dispatch(i)
, 从第 i
个中间件开始执行, 如果中间件数组中存在第 i
个函数,就调用该函数,并传入 ctx
和一个匿名函数 () => dispatch(i + 1)
作为 next
参数, 每个中间件调用完毕后,返回的是一个 Promise
。由于每个中间件返回的是 Promise
,通过递归调用 dispatch(i + 1)
,整个调用过程就被包装成一个 Promise
链。这就确保了中间件按照顺序执行,并且每个中间件在调用 await next()
后,会等待后续中间件执行完毕再继续自己的后置逻辑。在实现过程中, 会有一个 index
记录上次执行的中间件索引, 在 dispatch
函数内部开始前会比较当前要执行的中间件索引与上次执行的中间件索引, 如果当前的索引小于等于上次的索引, 说明 next()
已经被调用过了,抛出错误, 保证 next()
只能被调用一次。 如果 next()
被多次调用,就可能导致意外的行为: 1. 多次执行同一个中间件,导致请求流程紊乱; 2. 破坏 Promise
链,破坏洋葱模型, 使整个执行流程不可预测;
Koa
中间件基于 compose
来组合中间件。这个模块通过遍历中间件数组,并依次返回一个 Promise
链,使得中间件调用不会形成深层的同步递归,而是通过事件循环来调度执行。这种设计方式有效避免了传统递归调用带来的栈溢出风险,即使中间件数量很多,也不会消耗过多的同步调用栈。由于大多数 JavaScript
引擎(如 V8
)并未普遍支持尾调用优化,Koa
通过异步编程模式(async/await
+ Promise
链)实现了类似效果,确保中间件调用的高效性和稳定性。如果可以的话, 还是可以把 await next()
放在尾部,避免在其后有复杂的同步运算或递归调用。虽然在实际场景中很多中间件都需要做前置和后置处理,但保持调用链的简洁性有助于减少额外的栈帧积累。尾调用扩展: 尾调用 Tail Call
, 指的是在一个函数的最后一步直接调用另一个函数(或者自己),并将这个调用的返回结果直接返回给调用者。 尾调用 Tail Call
在尾调用的位置,当前函数不需要保留自己的执行上下文(例如局部变量、后续操作等),因此理论上可以复用当前的栈帧。如果 JavaScript
引擎实现了尾调用优化(Tail Call Optimization, TCO
),那么尾调用就可以避免调用栈不断增长的问题。因此 尾调用 不会在调用栈上增加新的堆栈帧(不会增加调用栈的长度),而是直接更新调用栈,调用栈所占空间始终是常量,节省了内存,避免了爆栈的可能性。所以, 我们在开发中, 尽量采用尾递归写法, 当需要递归操作时,可以重构函数,使递归调用处于尾调用位置。例如,在计算累加和、遍历树结构等场景下。ES6
规范要求:ECMAScript 2015(ES6)
规范中要求实现尾调用优化,以便在尾调用位置复用调用栈。但这要求是在严格模式下进行的。尽管理论上 ES6
规范支持尾调用优化,但目前主流的 JavaScript
引擎(如 V8
、SpiderMonkey
等)并未普遍实现这一特性。因此,在实际开发中,尽管我们可以采用尾递归的写法,但不能完全依赖底层引擎来保证优化效果。
二、实现
function compose(middleware) {
if (!Array.isArray(middleware)) {
return new TypeError("middleware 必须是一个函数数组");
}
for (const fn of middleware) {
if (typeof fn !== "function") {
return new TypeError("middleware 必须是一个函数");
}
}
return function (context, next) {
let index = -1;
function dispatch(i) {
if (i <= index) {
return Promise.reject(new Error("next() 函数不可以调用多次"));
}
index = i;
let fn = middleware[index];
if (index === middleware.length) {
fn = next;
}
if (!fn) {
return Promise.resolve();
}
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (error) {
return Promise.reject(error);
}
}
return dispatch(0);
};
}
三、调试
let fn1 = async function (context, next) {
console.log("fn1 之前");
await next();
console.log("fn1 之后");
};
let fn2 = async function (context, next) {
console.log("fn2 之前");
await next();
console.log("fn2 之后");
};
let fn3 = async function (context, next) {
console.log("fn3 之前");
await next(); // 最后一个中间件的 next 可有可无
console.log("fn3 之后");
};
let middleware = [fn1, fn2, fn3];
compose(middleware)({});