likes
comments
collection
share

从compose角度去看前端库(redux、koa)的中间件机制

作者站长头像
站长
· 阅读数 16

前言

阅读过一些源码的同学,都或多或少的了解过compose函数,比如expressreduxkoa的中间件,都用到了这个概念,他是函数式编程里面比较重要的概念之一。但是相信大家也发现了,虽然他们都叫compose,可是内部的实现却又略有不同,middleware的函数设计也不一样,本篇文章将从最简单的compose函数开始说起,逐步带大家了解不同compose实现的差异。

函数式基础

进入正文之前,先介绍两个函数式的概念:

柯里化

常见的对于柯里化的解释就是将接收多个参数的函数变换成接收一个单一参数的函数,换句话说,就是对于一个接收多个参数的函数,经过柯里化之后,我们可以逐步的调用这个函数,传递部分参数,每次调用他都会返回一个新的函数去处理剩下的参数

举一个非常简单的例子:

这里有一个函数:

function add(a, b, c) {
  return a + b + c;
}

根据上面的定义,我们会有一个curry的函数,他接收add函数,返回柯里化后的函数,并可以满足:

curryAdd = curry(add)
curryAdd(1)(2)(3) //6
// 或者
curryAdd = curry(add)
curryAdd(1)(2, 3) //6

接下来就是如何实现这个curry函数了,其实他的思想很简单,就是通过对比已经接收的参数数量与目标函数的入参数量(f.length),如果大于等于,就调用目标函数计算结果,否则返回一个函数继续进行参数的接收,实现的方法有多种,我这里列出一种使用递归实现的方式:

function curry(fn, ...args1) {
  const length = fn.length;
  function _curryFn(...args2) {
    if (args2.length === length) {
      return fn.apply(this, args2);
    }
    return (...args) => _curryFn(...args2, ...args);
  }
  return _curryFn(...args1);
};

偏函数

前面的柯里化有一个缺陷,就是我们只能按照函数入参的顺序进行传参,如果对于前面的add函数,我们想先传递第2个参数,然后传递剩余参数该怎么办呢,这里就需要使用partial偏函数了。

例子:

// 先传递中间的参数2
partialAdd = partial(fn, undefined, 2)
// 输出结果
partialAdd(1, 3) // 6

实现也比较简单,partial函数会接收一个函数及部分参数args,需要注意的是,部分参数需要按照接收函数的默认参数顺序进行传递,如果不传的填undefined,返回的新函数内部会把剩余参数与初始传递的参数进行合并,代码如下:

function partial(fn, ...args) {
  return function (...args2) {
    args = args.map((arg) => {
      if (arg === undefined) {
        return args2.shift();
      }
      return arg;
    });
    return fn(...args, ...args2);
  };
};

总结

总的来说,柯里化偏函数都是接收部分参数并返回一个含有更少参数函数的方法,只是在用法和实现上稍有不同。

函数式组合

接下来我们讲一下基本的函数式组合的概念,即compose

假设我们有以下两个函数addmul

function add(a, b) {
  return a + b;
}

function mul(a, b) {
  return a * b;
}

现在我们要定义一个函数,输入为x,输出为2 * x + 3,该如何做呢? 很容易可以想到:

function mulAndAdd(x) {
  return add(mul(x, 2), 3);
}
f(2) // 7

如果把前面柯里化的知识用到,则可以这样:

const add3 = curry(add, 3);
const mul2 = curry(mul, 2);

function composeMulAndAdd(x) {
  return add3(mul2(x));
}
g(2) // 7

观察composeMulAndAdd这个函数,我们会发现他是将add3mul2的函数组合起来了,通过嵌套的方式完成了计算,但是试想一下,如果我们有多个函数需要组合,嵌套后的调用可能难以阅读和维护:add3(mul2(mul2(add3())))

观察下他们的结构会发现,他们都是接收一个参数后将参数返回给另一个参数继续执行,我们能不能将这块逻辑提取出来呢?

具体做法就是,接收一个函数数组,循环调用他们,并把前一个调用的返回值传递给下一个函数,具体做法如下:

function compose(...funcs) {
  return (result) => {
    while (funcs.length) {
      result = funcs.pop()(result);
    }
    return result;
  };
};

有没有更简单的方法呢,当然是用reduce啦:

function compose(...funcs) {
  return (result) => {
    return funcs.reduceRight((result, fn) => {
      return fn(result);
    }, result);
  };
};

这里使用reduceRight的原因是compose函数的数组调用顺序是从后往前进行的,所以数组最后一个函数会优先调用。

redux 中间件

相信很多人都对redux的中间件有些许疑惑,尤其是他的compose函数,同样是组合,为啥他的中间件函数是一个需要三次传参的高阶函数,这三次传参又是如何工作的呢,且听我娓娓道来~

中间件使用

这一节会讲一下redux中间件的实现,没有看过redux原理的同学可以先去看一些相关的文章,这里不再赘述。

redux中间件做的事情实际上是增强dispatch,他拦截了原本dispatchreducer的过程。

先来看看中间件是如何使用的,假设我们有这样三个中间件:

const logger1 = (store) => (next) => (action) => {
  console.log("进入log1");
  let result = next(action);
  console.log("离开log1");
  return result;
};

const logger2 = (store) => (next) => (action) => {
  console.log("进入log2");
  let result = next(action);
  console.log("离开log2");
  return result;
};

const logger3 = (store) => (next) => (action) => {
  console.log("进入log3");
  let result = next(action);
  console.log("离开log3");
  return result;
};

我们按照中间件的使用方法编写一个简单的redux例子:

const middlewares = require("./middlewares");
const { logger1, logger2, logger3 } = middlewares;
const redux = require("redux");

const createStore = redux.createStore;

const initialState = {
  counter: 0,
};

const reducer = (store = initialState, action) => {
  // 标记一下
  console.log("reducer");
  switch (action.type) {
    case "INC_COUNTER":
      return {
        counter: store.counter + 1,
      };
    default: {
      return store;
    }
  }
};

const store = createStore(
  reducer,
  initialState,
  redux.applyMiddleware(logger1, logger2, logger3)
);

store.dispatch({ type: "INC_COUNTER" });
console.log(store.getState());

当我们运行这段代码,控制台将会打印:

从compose角度去看前端库(redux、koa)的中间件机制

其实这就是一个标准的洋葱圈模型,而这个效果就是使用compose函数组合前面的中间件来实现的。

中间件源码解析

下面我们就看一下这个中间件原理是什么。

首先我们先看下中间件函数的书写,他是由具有三个传参store => next => action的高阶函数实现的:

store

store非常好理解,他并没有参与到组合的过程中来,而是在组合之前就被执行掉了,源码在这

它的作用是以闭包的方式为后续的函数提供getStatedispatch两个接口,也就是源码里的middlewareApimiddlewares.map 将这个 middlewareApi 作为参数执行了一遍中间件,所以中间件第一级参数 store 就是这么来的。

next

既然store是在组合组合前就已经被调用了,为了简化源码,方便后续的解析,我们干脆去掉store这一层,这样我们的middlewares就变成了:

const logger1 = (next) => (action) => {
  console.log("进入log1");
  let result = next(action);
  console.log("离开log1");
  return result;
};

const logger2 = (next) => (action) => {
  console.log("进入log2");
  let result = next(action);
  console.log("离开log2");
  return result;
};

const logger3 = (next) => (action) => {
  console.log("进入log3");
  let result = next(action);
  console.log("离开log3");
  return result;
};

// 额外定义一个dispatch
const dispatch = (action) => {
  console.log("reducer");
};

下面我们想一下如何才能实现刚刚的洋葱圈的形式呢?

仔细看上面的中间件,其实我们最终要调用的是下面这个结构:

(action) => {
  const result = next()
  return result;
};

也就是next这一层是用来接收参数下一个中间件函数的。

对于logger1来说,他的下一层要调用logger2,所以我们可以传入logger2函数,以此类推,logger3最终传入的next就是dispatch,他是未增强的、最原始的dispatch函数,具体结构是这样的:

// 伪代码
enhancer = logger1(    
              console.log('进入logger1')    
                  logger2(        
                      console.log('进入logger2')        
                          logger3(            
                              console.log('进入logger3')     
                              dispatch()            
                              console.log('离开logger3')        
                          )        
                      console.log('离开logger2')    
                  )    
              console.log('离开logger1')
            )

我们实际要做的,就是一层一层的传入next函数,那么最终组合后的代码就是:

const enhancer = logger1(
                  // logger1 需要内部调用logger2
                  logger2(
                    // logger2 需要内部调用logger3
                    logger3(
                      // logger3 需要内部调用dispatch
                      dispatch
                    )
                  )
                );

这样看可能不清楚,我把注释去掉:

const enhancer = logger1(logger2(logger3(dispatch)));

有没有感觉似曾相识呢? 其实这行代码的原理和

const mulAndAdd = add3(mul2(x))

是一样的!

只不过mulAndAdd是对x这个值做了处理,让他先乘2再加3,然后返回一个新的值,

enhancer做的是对dispatch这个原始的函数做处理,在他外层不断的包裹其他的函数,最后返回一个增强的函数!

那这么说,我是不是可以直接拿上面写的compose函数来用了?

当然可以,我们来试试:

// ……省略前面的代码
function compose(...funcs) {
  return (result) => {
    return funcs.reduceRight((result, fn) => {
      return fn(result);
    }, result);
  };
}

const enhancer = compose(logger1, logger2, logger3)(dispatch);

const enhancer = compose(logger1, logger2, logger3)(dispatch);

const action = {
  type: "add",
  payload: 1,
};

enhancer(action);

没有任何问题:

从compose角度去看前端库(redux、koa)的中间件机制

有人会说,不对啊,我记得redux不是这么实现的呀,说的没错,他们是这样实现的:

function compose(...middlewares) {
  return middlewares.reduce((prev, cur) => {
    return (...args) => {
      return prev(cur(...args));
    };
  });
}

其实效果都是一样的,只不过他的reduce最后返回的是一个函数,这个函数最终回去接收那个dispatch,为了方便说明,这里稍微修改一下:

function compose(...middlewares) {
  return middlewares.reduce((prev, cur) => {
    return (next) => {
      return prev(cur(next));
    };
  });
}

我们来看一下整个执行过程,首先把中间件传入:

const isComposed = compose(logger1, logger2, logger3)

这时isComposed是实际上长这样:

(dispatch) => {
  return ((next1) => {
    return ((next2) => {
      return logger1(next2);
    })(logger2(next1));
  })(logger3(dispatch));
};

这时组合后的函数只欠把dispatch传入,就可以完成增强了:

const enhanced = isComposed(dispatch)

这块确实比较难以理解,大家可以试着在脑中思考下整个运行过程。

koa 中间件

在讲koa之前,我们回顾一下前面的内容,对于函数式组合那一小节中的2 * x + 3这个例子,我们的add3mul2都是输入一个数,输出一个数。在redux中,我们要增强dispatch,所以我们的输入是函数,输出也是函数,这也就对应了为什么reduxmiddleware需要使用高阶函数,因为他需要接收next这个函数,从而返回增强next函数。也就是说,到目前为止,如果我们要compose,至少要做到输入和输出的类型相同

但是这里我简单模拟下koa的中间件长什么样:

const logger1 = (ctx, next) => {
  console.log("进入log1");
  next();
  console.log("离开log1");
};

const logger2 = (ctx, next) => {
  console.log("进入log2");
  next();
  console.log("离开log2");
};

const logger3 = (ctx, next) => {
  console.log("进入log3");
  next();
  console.log("离开log3");
};


// koa是没有dispatch的,为了和上文一致,这里先留着
const dispatch = (ctx) => {
  console.log(ctx);
};

这这这。。这不讲武德啊!

传入的参数有两个,传出的参数有一个,这怎么做中间件呢?

尝试实现

其实我们可以投机取巧一下,既然你不符合我compose的条件,没有条件创造条件嘛:

const middlewares = [logger1, logger2, logger3].map((middleware) => {
  return (next) => {
    return partial(middleware, undefined, next);
  };
});

我们把每个中间件外面包括一层,通过partial函数提前把next传进去,这样每个中间件实际上和redux就相同了。

我们测试下:

function compose(...funcs) {
  return (result) => {
    return funcs.reduceRight((result, fn) => {
      return fn(result);
    }, result);
  };
}

const enhancer = compose(...middlewares)(dispatch);
enhancer({
  ctx: "ctx",
});

从compose角度去看前端库(redux、koa)的中间件机制

我们会发现ctx的值丢了,这是因为我们之前的next是带着ctx的,而koa的实现里next并不会传递值。那该怎么办呢?

这里举两种实现的方式,就不详细讲解了:

实现一

function compose(...funcs) {
  return (ctx) => (dispatch) =>
    funcs.reduceRight(
      (prev, cur) => {
        return () => {
          cur(ctx, prev);
        };
      },
      () => {
        dispatch(ctx);
      }
    );
}

const enhancer = compose(...middlewares)({
  ctx: "ctx",
})(dispatch);

enhancer();

从compose角度去看前端库(redux、koa)的中间件机制

实现二

注意实现二里并没有调用dispatch,前面使用dispatch只是为了适配compose函数

function compose(...middlewares) {
  return (ctx) => {
    const gen = function* (middlewares) {
      for (let i = 0; i < middlewares.length; i++) {
        yield middlewares[i];
      }
    };
    const g = gen(middlewares);
    const next = () => {
      const middleware = g.next().value;
      if (middleware) {
        return middleware(ctx, next);
      }
    };
    next();
  };
}

const enhancer = compose(...middlewares);

enhancer({
  ctx: "ctx",
});

两种方法都是提前把ctx传入,用闭包的方式使变量持久化保存。

官方实现

这是koa compose的官方实现,有兴趣的小伙伴可以看一下,与前面讲的不同的是,官方实现是加入了异步的:

koa-compose

结尾

本篇文章到此为止了,其实看了reduxkoa的中间件后,明白了一个道理,就是我们不应该被compose束缚住,两个库的中间件都不是标标准准的compose结构,但都设计巧妙,完成了各自的任务。所以我们在设计一个中间件的时候,首先要考虑的是我们需要什么,然后是该如何实现,最终就会发现我们的实现是compose这一类的思想。

就像读源码,如果我们不了解作者为什么写,直接读源码,实际上是很困难的,甚至读完都不知道他要做什么,但如果我们开始就先思考这个文件,或者这个函数做的事情,他的输入是什么,输出又是什么,如果自己是作者,又会如何去做,带着问题去看源码,效率会高很多。

感谢大家的阅读!