Koa
是当下主流 NodeJS 框架,以轻量见长,而它中间件机制与相对传统的 Express
支持了异步,因此编码时常用 async/await
,提升了可读性,使代码变得更优雅,上一篇文章 NodeJS 进阶 —— Koa 源码分析,也对 “洋葱模型” 和实现它的 compose
进行分析,因为我的以为 compose
的编程思想比较重要,应用普遍,因此本篇借着 “洋葱模型” 的话题,打算用四种方式来实现 compose
。编程
若是你已经使用 Koa
对 “洋葱模型” 这个词必定不陌生,它就是 Koa
中间件的一种串行机制,而且是支持异步的,下面是一个表达 “洋葱模型” 的经典案例。数组
const Koa = require("koa");
const app = new Koa();
app.use(asycn (ctx, next) => {
console.log(1);
await next();
console.log(2);
});
app.use(asycn (ctx, next) => {
console.log(3);
await next();
console.log(4);
});
app.use(asycn (ctx, next) => {
console.log(5);
await next();
console.log(6);
});
app.listen(3000);
// 1
// 3
// 5
// 6
// 4
// 2复制代码
上面的写法咱们按照官方推荐,使用了 async/await
,但若是是同步代码不使用也没有关系,这里简单的分析一下执行机制,第一个中间件函数中若是执行了 next
,则下一个中间件会被执行,依次类推,就有了咱们上面的结果,而在 Koa
源码中,这一功能是靠一个 compose
方法实现的,咱们本文四种实现 compose
的方式中实现同步和异步,并附带对应的案例来验证。bash
在真正建立 compose
方法以前应该先作些准备工做,好比建立一个 app
对象来顶替 Koa
建立出的实例对象,并添加 use
方法和管理中间件的数组 middlewares
。app
// 模拟 Koa 建立的实例
const app = {
middlewares: []
};
// 建立 use 方法
app.use = function(fn) {
app.middlewares.push(fn);
};
// app.compose.....
module.exports = app;复制代码
上面的模块中导出了 app
对象,并建立了存储中间件函数的 middlewares
和添加中间件的 use
方法,由于不管用哪一种方式实现 compose
这些都是须要的,只是 compose
逻辑的不一样,因此后面的代码块中会只写 compose
方法。框架
首先介绍的是 Koa
源码中的实现方式,在 Koa
源码中实际上是经过 koa-compose
中间件来实现的,咱们在这里将这个模块的核心逻辑抽取出来,用咱们本身的方式实现,因为重点在于分析 compose
的原理,因此 ctx
参数就被去掉了,由于咱们不会使用它,重点是 next
参数。koa
app.compose = function() {
// 递归函数
function dispatch(index) {
// 若是全部中间件都执行完跳出
if (index === app.middlewares.length) return;
// 取出第 index 个中间件并执行
const route = app.middlewares[index];
return route(() => dispatch(index + 1));
}
// 取出第一个中间件函数执行
dispatch(0);
};复制代码
上面是同步的实现,经过递归函数 dispatch
的执行取出了数组中的第一个中间件函数并执行,在执行时传入了一个函数,并递归执行了 dispatch
,传入的参数 +1
,这样就执行了下一个中间件函数,依次类推,直到全部中间件都执行完毕,不知足中间件执行条件时,会跳出,这样就按照上面案例中 1 3 5 6 4 2
的状况执行,测试例子以下(同步上、异步下)。异步
const app = require("./app");
app.use(next => {
console.log(1);
next();
console.log(2);
});
app.use(next => {
console.log(3);
next();
console.log(4);
});
app.use(next => {
console.log(5);
next();
console.log(6);
});
app.compose();
// 1
// 3
// 5
// 6
// 4
// 2复制代码
const app = require("./app");
// 异步函数
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log("hello");
}, 3000);
});
}
app.use(async next => {
console.log(1);
await next();
console.log(2);
});
app.use(async next => {
console.log(3);
await fn(); // 调用异步函数
await next();
console.log(4);
});
app.use(async next => {
console.log(5);
await next();
console.log(6);
});
app.compose();复制代码
咱们发现若是案例中按照 Koa
的推荐写法,即便用 async
函数,都会经过,可是在给 use
传参时可能会传入普通函数或 async
函数,咱们要将全部中间件的返回值都包装成 Promise 来兼容两种状况,其实在 Koa
中 compose
最后返回的也是 Promise,是为了后续的逻辑的编写,可是如今并不支持,下面来解决这两个问题。async
compose
的其余实现方式中,都是使用 sync-test.js
和 async-test.js
验证,因此后面就再也不重复了。
app.compose = function() {
// 递归函数
function dispatch(index) {
// 若是全部中间件都执行完跳出,并返回一个 Promise
if (index === app.middlewares.length) return Promise.resolve();
// 取出第 index 个中间件并执行
const route = app.middlewares[index];
// 执行后返回成功态的 Promise
return Promise.resolve(route(() => dispatch(index + 1)));
}
// 取出第一个中间件函数执行
dispatch(0);
};复制代码
咱们知道 async
函数中 await
后面执行的异步代码要实现等待,带异步执行后继续向下执行,须要等待 Promise,因此咱们将每个中间件函数在调用时最后都返回了一个成功态的 Promise,使用 async-test.js
进行测试,发现结果为 1 3 hello(3s后) 5 6 4 2
。函数
app.compose = function() {
return app.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};复制代码
上面的代码看起来不太好理解,咱们不妨根据案例把这段代码拆解开,假设 middlewares
中存储的三个中间件函数分别为 fn1
、fn2
和 fn3
,因为使用的是 reduceRight
方法,因此是逆序归并,第一次 a
表明初始值(空函数),b
表明 fn3
,而执行 fn3
返回了一个函数,这个函数再做为下一次归并的 a
,而 fn2
做为 b
,依次类推,过程以下。源码分析
// 第 1 次 reduceRight 的返回值,下一次将做为 a
() => fn3(() => {});
// 第 2 次 reduceRight 的返回值,下一次将做为 a
() => fn2(() => fn3(() => {}));
// 第 3 次 reduceRight 的返回值,下一次将做为 a
() => fn1(() => fn2(() => fn3(() => {})));复制代码
由上面的拆解过程能够看出,若是咱们调用了这个函数会先执行 fn1
,若是调用 next
则会执行 fn2
,若是一样调用 next
则会执行 fn3
,fn3
已是最后一个中间件函数了,再次调 next
会执行咱们最初传入的空函数,这也是为何要将 reduceRight
的初始值设置成一个空函数,就是防止最后一个中间件调用 next
而报错。
通过测试上面的代码不会出现顺序错乱的状况,可是在 compose
执行后,咱们但愿进行一些后续的操做,因此但愿返回的是 Promise,而咱们又但愿传入给 use
的中间件函数既能够是普通函数,又能够是 async
函数,这就要咱们的 compose
彻底支持异步。
app.compose = function() {
return Promise.resolve(
app.middlewares.reduceRight(
(a, b) => () => Promise.resolve(b(a)),
() => Promise.resolve();
)()
);
};复制代码
参考同步的分析过程,因为最后一个中间件执行后执行的空函数内必定没有任何逻辑,但为遇到异步代码能够继续执行(好比执行 next
后又调用了 then
),都处理成了 Promise,保证了 reduceRight
每一次归并的时候返回的函数内都返回了一个 Promise,这样就彻底兼容了 async
和普通函数,当全部中间件执行完毕,也返回了一个 Promise,这样 compose
就能够调用 then
方法执行后续逻辑。
app.compose = function() {
return app.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};复制代码
Redux
新版本中将 compose
的逻辑作了些改动,将本来的 reduceRight
换成 reduce
,也就是说将逆序归并改成了正序,咱们不必定和 Redux
源码彻底相同,是根据相同的思路来实现串行中间件的需求。
我的以为改为正序归并后更难理解,因此仍是将上面代码结合案例进行拆分,中间件依然是 fn1
、fn2
和 fn3
,因为 reduce
并无传入初始值,因此此时 a
为 fn1
,b
为 fn2
。
// 第 1 次 reduce 的返回值,下一次将做为 a
arg => fn1(() => fn2(arg));
// 第 2 次 reduce 的返回值,下一次将做为 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));
// 等价于...
arg => fn1(() => fn2(() => fn3(arg)));
// 执行最后返回的函数链接中间件,返回值等价于...
fn1(() => fn2(() => fn3(() => {})));复制代码
因此在调用 reduce
最后返回的函数时,传入了一个空函数做为参数,其实这个参数最后传递给了 fn3
,也就是第三个中间件,这样保证了在最后一个中间件调用 next
时不会报错。
下面有个更艰巨的任务,就是将上面的代码更改成支持异步,实现以下。
app.compose = function() {
return Promise.resolve(
app.middlewares.reduce((a, b) => arg =>
Promise.resolve(a(() => b(arg)))
)(() => Promise.resolve())
);
};复制代码
实现异步其实与逆序归并是一个套路,就是让每个中间件函数的返回值都是 Promise,并让 compose
也返回 Promise。
这个版本是我在以前在学习 Koa
源码时偶然在一位大佬的一篇分析 Koa
原理的文章中看到的(翻了半天实在没找到连接),在这里也拿出来和你们分享一下,因为是利用 async
函数实现的,因此默认就是支持异步的,由于 async
函数会返回一个 Promise。
app.compose = function() {
// 自执行 async 函数返回 Promise
return (async function () {
// 定义默认的 next,最后一个中间件内执行的 next
let next = async () => Promise.resolve();
// middleware 为每个中间件函数,oldNext 为每一个中间件函数中的 next
// 函数返回一个 async 做为新的 next,async 执行返回 Promise,解决异步问题
function createNext(middleware, oldNext) {
return async () => {
await middleware(oldNext);
}
}
// 反向遍历中间件数组,先把 next 传给最后一个中间件函数
// 将新的中间件函数存入 next 变量
// 调用下一个中间件函数,将新生成的 next 传入
for (let i = app.middlewares.length - 1; i >= 0; i--) {
next = createNext(app.middlewares[i], next);
}
await next();
})();
};复制代码
上面代码中的 next
是一个只返回成功态 Promise 的函数,能够理解为其余实现方式中最后一个中间件调用的 next
,而数组 middlewares
恰好是反向遍历的,取到的第一个值就是最后一个中间件,而调用 createNext
做用是返回一个新的能够执行数组中最后一个中间件的 async
函数,并传入了初始的 next
,这个返回的 async
函数做为新的 next
,再取到倒数第二个中间件,调用 createNext
,又返回了一个 async
函数,函数内依然是倒数第二个中间件的执行,传入的 next
就是上次新生成的 next
,这样依次类推到第一个中间件。
所以执行第一个中间件返回的 next
则会执行传入的上一个生成的 next
函数,就会执行第二个中间件,就会执行第二个中间件中的 next
,就这样直到执行完最初定义的的 next
,经过案例的验证,执行结果与洋葱模型彻底相同。
至于异步的问题,每次执行的 next
都是 async
函数,执行后返回的都是 Promise,而最外层的自执行 async
函数返回的也是 Promise,也就是说 compose
最后返回的是 Promise,所以彻底支持异步。
或许你看完这几种方式会以为,仍是 Koa
对于 compose
的实现方式最容易理解,你也可能和我同样在感慨 Redux
的两种实现方式和 async
函数实现方式是如此的巧妙,偏偏 JavaScript 在被别人诟病 “弱类型”、“不严谨” 的同时,就是如此的具备灵活性和创造性,咱们没法判断这是优势仍是缺点(仁者见仁,智者见智),但有一点是确定的,学习 JavaScript 不要被强类型语言的 “墨守成规” 所束缚(我的观点,强类型语言开发者勿喷),就是要吸取这样巧妙的编程思想,写出 compose
这种优雅又高逼格的代码,路漫漫其修远兮,愿你在技术的路上 “一去不复返”。