持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情
在函数式编程中,有一个重要概念,那就是通过组合(function composition)的方式控制函数的链式调用。compose的思想随处可见,比如一些pipe/flow方法的实现,又比如各类框架中的中间件(koa、redux、webpack中的loader)的设计。
如上图所示,输入和输出之间会有多个函数,在一些设计模式中,或曰任链模式。这些个函数符合单一职责原则,将上一级的输入进行处理后输出给下一级。
本文我们将认识compose的实现方式,并探究一下koa-compose的内部原理!
compose的简单实现
首先,我们来看一下,如果要编排多个同步的纯函数,compose是如何实现的 😀。
const increase = x => x++;
const double = x => x*2;
const square = x => x*x;
上面代码提供了三个不同的数学运算函数。
当不使用compose时,其调用方式如下:
const inputX = 4;
const output = square(double(increase(inputX)))
// output
// 100
如果使用compose调用的方式,会简化函数嵌套,具有更好的可读性:
const compute = compose(square, double, increase)
const output = compute(inputX)
// output
// 100
如何来实现这样的一个compose函数呢?实际上,在JS的数组方法中,有一个reduce方法,通过他可以在遍历数组的同时,累积数组上一项的函数执行结果并传递给数组的下一项。
具体的方法定义可以参照官档。同时,还有一个类似的方法,reduceRight,他和reduce的唯一区别在于reduceRight遍历数组项的顺序是从右到左,最后一项即为遍历的第一项(reduce是从左到右的正向遍历)。
基于这个方法,如果我们的compose接受系列的执行函数,那么就可以自动地从数组的第一个函数执行到最后一个函数,并且传递每一级执行的输出值作为下一级的输入。具体来看一下实现吧!
// compose based on reduce
const compose = (...fns) => (input) => fns.reduce((prevOutput, fn) => fn(prevOutput), input)
// pipe based on reduceRight
const pipe = (...fns) => (input) => fns.reduce((prevOutput, fn) => fn(prevOutput), input)
在上述实现中,pipe对接收的函数数组是从左到右执行的,而compose正好相反。
compose处理异步函数
如果中间的处理函数是异步函数,那么 compose 应该如何实现呢?
在这之前,我们测试一下之前的简版 compose 是否可以正常运行包含了异步函数的函数数组。
const asyncFn = (x) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(x)
}, 1000)
});
};
async function one(x) {
const res = await asyncFn(x);
return res;
}
function two(x) {
return x * 2;
}
function three(x) {
return x * x;
}
const pipe = (...fns) => (x) => fns.reduce((pre, fn) => fn(pre), x)
const res = pipe(one, two, three)(4)
// res
// NaN
上述代码在执行后,我们发现结果值竟然是一个NaN,这是因为在one这个异步函数执行后,返回值是一个Promise对象,在two函数中直接对这个对象进行了运算操作。上述代码是没有支持异步函数调用的。
因此,我们将作出如下的改造:
// compose based on reduceRight
const compose = (...fns) => x => fns.reduceRight((pre, fn) => pre.then(fn), Promise.resolve(x))
// pipe based on reduce
const pipe = (...fns) => x => fns.reduce((pre, fn) => pre.then(fn), Promise.resolve(x))
在上面的代码中,将初始值使用Promise.resolve静态方法将输入值改造为一个promise包裹的对象,并在每一级函数处理时返回Promise,我们试着运行一下:
const asyncFn = (x) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(x)
}, 1000)
});
};
async function one(x) {
const res = await asyncFn(x);
return res;
}
function two(x) {
return x * 2;
}
function three(x) {
return x * x;
}
const pipe = (...fns) => (x) => fns.reduce((pre, fn) => pre.then(fn), Promise.resolve(x))
pipe(one, two, three)(4).then(res => {
console.log(res)
})
// res
// 64
现在,我们的compose(pipe)可以支持异步的中间函数了 🤩。
koa-compose的实现
koa的中间件应该是非常出名的,这个库本身的源码实现也非常简洁 😍。koa-compose的作用就是将通过app.use收集到的中间件数组(middlewares)依次执行。但koa-compose和我们上面提到的自行实现的compose又有不少区别emm。
先认识一下koa-compose的调用形式吧。
const compose = require("koa-compose");
function one(ctx, next) {
console.log("1 begin", ctx);
next().then(() => {
console.log("1 next");
});
console.log("1 done");
}
function two(ctx, next) {
console.log("2 begin", ctx);
ctx++;
next().then(() => {
console.log("2 next");
});
console.log("2 done");
}
function three(ctx, next) {
console.log("3", ctx);
next();
}
const middlewares = compose([one, two, three]);
const initalParam = {
key: "string value"
};
middlewares(initalParam).then(() => {
console.log("finish");
});
// output
// 1 begin {key: "string value"}
// 2 begin {key: "string value"}
// 3 {key: "string value"}
// 2 done
// 1 done
// 2 next
// 1 next
// finish
相信了解或者使用过koa的朋友对这里的ctx和next函数还是很熟悉的。ctx在koa中是请求上下文对象,调用next表示执行下一个中间件函数。
显然,在每一个中间件函数中我们都可以读取或者修改这个ctx,因此,koa-compose组合的中间件函数并不是纯函数,每一级函数都可以作用于ctx。
而next调用即可进入下一个中间件函数,next之后的代码会等到下游的中间件函数执行完成后才执行。而next执行后会返回一个Promise对象,该Promise对象then方法中的函数又是在所有同步代码执行完成后才执行。
可以说,正是这个compose函数才使得koa的洋葱圈模型的异步执行方式成为可能。
下面会贴上koa-compose的源码 👇
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!')
}
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
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)
}
}
}
}
上述函数实现的关键点如下:
- 内部的dispatch返回一个Promise,既保证中间件组成的调用链最后返回的是Promise,又保证了next函数调用后的结果是Promise 😲。
- dispatch参数中的i对应当前middleware在数组中的索引,index是指向上一个执行的middleware的游标,index在当前中间件执行时,会被更新。在每次进入中间件时,会有(i≤ index)的判断,保证在一个中间件中不能两次调用next函数。
解释一下最开始使用koa-compose的执行顺序~当我们调用next函数时,实际上是进入了下一个中间件函数的执行栈,这里的入栈和执行都是连续的,只有当最后一个中间件执行完,才会依次出栈,也才会执行next之后的同步代码。只有执行栈中的同步代码全部完成,才会执行next().then()。
最后,小结一下。
compose 即函数组合,函数调用
a(b(c(x)))使用 compose 组合后变为新函数d = compose(a, b, c);d(x)。
koa-compose 也使用了相同的思想,对异步函数有更优雅的支持。
compose可以帮助我们去除函数嵌套的执行方式,也可以实现继发的异步调用。compose将每一个单一职责的函数组合为一个流水线或者说职责链。