函数式编程之compose

151 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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将每一个单一职责的函数组合为一个流水线或者说职责链。