- 本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第5期,链接:传送门。
- 撰写日期 2023-07-20,源码 koa-compose v4.0.0
我认识的 Koa
如果你接触过 node.js 的开发,肯定听说过 Express/Koa,其中 Koa 更是因为其轻量、优雅的中间件设计而成为经典的研发面试题目。
相信上面这张经典的图例,很多人都见到过,那么 Koa 中间件的设计(也即洋葱模型)是怎么实现的呢?带着疑问,开启这次源码共读之旅。
思考一下,下面代码执行结果
js
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);
await next();
console.log(4);
});
由上文洋葱模型的图例,我们可以得知,输出结果明显是:
shell
1
2
3
4
5
6
这个是怎么实现的呢,能够让注册中间件从外到内执行,然后等待 next 执行后,能够逆序往外再执行剩下逻辑。
koa-compose
使以上洋葱模型生效的设计,就是 koa-compose 库;我们来看下它的源码实现
js
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
function dispatch (i) {
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 {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
🥲 啊这,全部源代码就这点?是的,除了导出那行代码以外,这就是全部源代码!!!
从以上代码,可以看到 compose
函数接收的一个 middleware 的数组(实际上就是上文使用 app.use 注册的那些函数),数据的每一项都需要是函数类型。
那么我们就可以得知,洋葱模型的中间件实现秘密就是后面那段 return function (context, next) {...}
里面了。
js
// 省略前面代码
return function (context, next) {
// last called middleware #
let index = -1
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// 取出 middleware 数据里面的某一项
let fn = middleware[i]
// 最后到达末尾,next 值为 undefined
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
// ??? 这段是怎么回事,递归 dispatch,bind ...
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
dispatch 函数
上面那段难以理解的递归 dispatch.bind()
操作就是关键,口述比较难以解释,那么启用调试打发看看:
在上两图的代码处打上断点,然后访问,调试执行
我们选择 Step into,进入 fn 执行内部看看
没错,就是第一个中间件注册的地方,然后 await next()
执行后呢?
等等,执行回到原点?不过 i 变成了 1 ,断点到这里不得不感叹这短小精妙的代码竟是如此的优雅。这里的关键就是 dispatch.bind(null, i + 1)
这个高阶函数传参;bind 方法可以返回一个新的函数,且还可以把原函数接收的参数,提前传递到新返回的函数内 !(这是 bind 方法后面参数在起作用);至此,可以解释得通了,取出 middleware 当前项的函数执行后,传递的 next 方法实际上是下一个 middlware 项的函数再包装(使用 dispatch.bind),并且 dispatch 方法总是返回一个 Promise,因此 next 方法执行可以被 await 等待进入异步任务队列,且这里是调用了下一个中间件的函数 (栈结构)。栈结构的先进后出特性,实现从外到内执行,然后等待 next 执行后,能够逆序往外再执行剩下逻辑, 以上就是经典的"洋葱模型"个中细节和原理。
简化代码,加强理解
js
// 剔除其余代码,简化后,这样更好理解一些
const [fn1, fn2, fn3] = middleware;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};
当 middleware 到达最后一项是,返回一个 Promise.resolve()
纯净的 Promise 来作为 next,执行完之后就会逆序执行中间件 await next()
之后的代码逻辑了。
总结
koa-compose
源代码真的很精妙,有种大巧不工,浑然天成的感觉,真的666,但第一次看还是容易迷惑,使用断点调试大法理解起来会更加清楚、深刻,里面高阶函数、闭包、Promise
、bind
等知识要灵活组合运用到一起,大大增加理解难度,学到了 😎。