w3ctech

【 js 基础 】【 源码学习 】柯里化和箭头函数

最近在看 redux 的源码,代码结构很简单,主要就是6个文件,其中 index.js 负责将剩余5个文件中定义的方法 export 出来,其他5个文件各自负责一个方法的实现。

redux目录结构

大部分代码比较简单,很容易看懂,但是在 applyMiddleware.js 中 有一个地方很有意思,用到了柯里化和箭头函数的组合。在增强 store,丰富 dispath 方法的时候,可能会用到多个 中间件,所以这个柯里化的嵌套可能会很深,导致对 箭头函数和柯里化 不是很熟悉的童鞋,一看源码就会有些理不清调用思路。

一、柯里化 柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

--来自 wiki

举个例子: 柯里化前:

1 function add(a, b) {
2     return a + b;
3 } // 执行 add 函数,一次传入两个参数即可
4 add(10, 2) // 12

柯里化后:

1 var add = function(a) {
2     return function(b) {
3         return a + b;
4     };
5 };
6 var addTen = add(10);
7 addTen(2); // 12

那么我们为什么要使用柯里化呢? 1、参数复用 2、延迟计算 3、提前返回 首先柯里化可以使得参数复用并且延迟计算,也就是说像上面的例子,比如我们的 a 值一直都是 10,只是 b 值在变化,那么这个时候用到柯里化,就可以减少一些重复性的传参。 比如 b 是 2,3,4 那么在柯里化前的调用就是:

add(10,2)
add(10,3)
add(10,4)

而在柯里化之后,就像上面的例子中写的,我们通过定义

var addTen = add(10);

将第一个参数 a 预置了 10, 之后我们只需要调用

addTen(2)
addTen(3)
addTen(4)

就可以了。

而在这个过程中,如果使用柯里化前的代码,或当即就把结果计算出来,而在柯里化之后,我们可以现传入一个 10,然后在想得到真正结果的时候再传入另一个参数,体现了延迟计算。

同时柯里化函数还可以 提前返回,很常见的一个例子,兼容现代浏览器以及IE浏览器的事件添加方法。我们正常情况不使用柯里化可能会这样写

1 var addEvent = function(el, type, fn, capture) {
 2     if (window.addEventListener) {
 3         el.addEventListener(type, function(e) {
 4             fn.call(el, e);
 5         }, capture);
 6     } else if (window.attachEvent) {
 7         el.attachEvent("on" + type, function(e) {
 8             fn.call(el, e);
 9         });
10     } 
11 };

这个时候我们没调用一次 addEvent,就会进行一次 if else 的判断,而其实具体用哪个方法进行方法的绑定的判断执行一次就已经知道了,所以我们可以使用柯里化来解决这个问题:

1 var addEvent = (function(){
 2     if (window.addEventListener) {
 3         return function(el, sType, fn, capture) {
 4             el.addEventListener(sType, function(e) {
 5                 fn.call(el, e);
 6             }, (capture));
 7         };
 8     } else if (window.attachEvent) {
 9         return function(el, sType, fn, capture) {
10             el.attachEvent("on" + sType, function(e) {
11                 fn.call(el, e);
12             });
13         };
14     }
15 })();

一开始的自执行函数,完成了对 addEvent 具体使用 哪个方法的判断,之后在调用传参的时候都是直接给了已经判断好的返回方法,所以使用了柯里化 减少了我们每次的判断,提前返回了我们需要的具体方法。

这里还要多提一点,我们也经常会听到高阶函数,那么高阶函数和柯里化是什么关系? 所谓高阶函数,即至少满足下列的一个条件的函数: a、接受一个或多个函数作为输入 b、输出一个函数 这样你就明白了,柯里化其实是高阶函数的一种实现。

二、箭头函数 箭头函数是ES6中新增的函数形式。 他的语法是:

1 (参数1, 参数2, …, 参数N) => {函数声明}
 2 (参数1, 参数2, …, 参数N) => 表达式(单一)
 3 //相当于:(参数1, 参数2, …, 参数N) =>{ return 表达式 }
 4 
 5 // 当只有一个参数时,圆括号是可选的:
 6 (单一参数) => {函数声明}
 7 单一参数 => {函数声明}
 8 
 9 // 没有参数的函数应该写成一对圆括号。
10 () => {函数声明}

举个例子:

1 var func = x => x * x;                  
2 // 简写函数 省略return
3 
4 var func = (x, y) => { return x + y; }; 
5 //常规编写 明确的返回值

这个转换成 es5 的格式就是

1 function (x) {
2     return x * x;
3 }
4 function (x, y) {
5     return x + y;
6 }

那么我们为什么要使用箭头函数? 主要的好处有两点: 1、更简短的函数 2、不绑定this 第一个优点是很容易理解的,你观察上面的例子,虽然很简单,但明显箭头函数的写法剩下了不少的代码,对照 es5 来看写的更加简洁了。 而第二个优点: 在箭头函数之前,每个新定义的函数都有它自己的 this值(在构造函数的情况下是一个新对象,在严格模式的函数调用中为 undefined),这个知识点,大家可以我的另一篇文章 【 js 基础 】【读书笔记】关于this 举个例子:

1 function Person() {
 2   // Person() 构造函数定义 `this`作为它自己的实例.
 3   this.age = 0;
 4 
 5   setInterval(function growUp() {
 6     // 在非严格模式, growUp()函数定义 `this`作为全局对象, 
 7     // 与在 Person()构造函数中定义的 `this`并不相同.
 8     this.age++;
 9   }, 1000);
10 }
11 
12 var p = new Person();

而我们通常会这样解决 setInterval 中 this 的指向问题,将 this 赋值给变量 self

1 function Person() {
2   var self = this;
3   self.age = 0;
4 
5   setInterval(function growUp() {
6     //  回调引用的是`that`变量, 其值是预期的对象. 
7     self.age++;
8   }, 1000);
9 }

而在箭头函数中,这个问题是不会出现的。因为箭头函数不会创建自己的this,它使用封闭执行上下文的this值。因此,在下面的写法中,传递给 setInterval 的函数内的 this 与封闭函数中的 this 值相同:

1 function Person(){
2   this.age = 0;
3 
4   setInterval(() => {
5     this.age++; // |this| 正确地指向person 对象
6   }, 1000);
7 }
8 
9 var p = new Person();

那么如果使用 call 和 apply 调用,会怎么样呢?

1 var adder = {
 2   base : 1,
 3     
 4   add : function(a) {
 5     var f = v => v + this.base;
 6     return f(a);
 7   },
 8 
 9   addThruCall: function(a) {
10     var f = v => v + this.base;
11     var b = {
12       base : 2
13     };
14             
15     return f.call(b, a);
16   }
17 };
18 
19 console.log(adder.add(1));         // 输出 2
20 console.log(adder.addThruCall(1)); // 仍然输出 2

也就是说通过 call() 或 apply() 方法调用一个函数时,只是传入了参数而已,对 this 并没有什么影响。

关于 箭头函数和柯里化,就说这么多,具体深入的学习大家可以去搜资料,因为有太多太多,写的很好的资料,我这里只是个背景知识。

三、柯里化和箭头函数的结合 先来一个简单的例子,咱们就利用上面的 add 方法: es5写法:

1 var add = function(a) {
2     return function(b) {
3         return a + b;
4     };
5 };

而当你加上了箭头

let add = a => b => a + b

就只有这样简单的一句。 目前比较起来,还是比较容易理解的,但是当没有给你 es5 的写法并且箭头也更加多,柯里化函数每层的调用、参数预置分散开来的时候,理解起来还是会有一些蒙的。

咱们来举个例子,理清一下如何去理解这样组合的思路。

1 //[p]roperty, [v]alue, [o]bject:
2 const is = p => v => o => o.hasOwnProperty(p) && o[p] == v;
3 
4 // outer:  p => [inner1 function, uses p]
5 // inner1: v => [inner2 function, uses p and v]
6 // inner2: o => o.hasOwnProperty(p) && o[p] = v;

这个函数看最后一个箭头后面的表达式很容易理解,就是对传进来的属性和值,在传进来的对象上的匹配情况,如果对象上有这个属性,并且对应的值也和传进来的值相等,就返回 true。

而在我们对这个函数的调用过程,可以灵活的分为三步。

第一步 传入 property 即 p 参数 ,这会返回一个需要 v 和 o 作为参数的函数:

v => o => o.hasOwnProperty(p) && o[p] == v;

第二步 传入 value 即 v 参数,这同样会返回一个需要 o 作为参数的函数:

o => o.hasOwnProperty(p) && o[p] == v;

在这个时候,咱们的表达式里只有 o 是未知的,所以当我们再调用一次,就会得到一个 boolean 结果。

也就是说如果有 n 个箭头,那么我们在 n-1 次调用之前,都只是在向表达式里传参,而伴随着第 n 次调用,就会直接返回结果。

这个的调用过程就像是拨洋葱,一层层的调用函数,到最后一层返回结果。

那么这样写有什么好处呢? 1、减少代码重复 2、提高代码重用性 举个例子来比较一下:

result = users
  .filter(x => x.hasOwnProperty('pets'))
  .filter(x => x.hasOwnProperty('title'))

1 const has = p => o => o.hasOwnProperty(p);
2 result = users
3   .filter(has('pets'))
4   .filter(has('title'))

优点还是很明显的。

最后咱们再来说一下 redux 源码中对以上知识的应用:

1 import compose from './compose'
 2 export default function applyMiddleware(...middlewares) {
 3   return createStore => (...args) => {
 4     const store = createStore(...args)
 5     let dispatch = () => {
 6       throw new Error(
 7         `Dispatching while constructing your middleware is not allowed. ` +
 8           `Other middleware would not be applied to this dispatch.`
 9       )
10     }
11     let chain = []
12 
13     const middlewareAPI = {
14       getState: store.getState,
15       dispatch: (...args) => dispatch(...args)
16     }
17     chain = middlewares.map(middleware => middleware(middlewareAPI))
18     dispatch = compose(...chain)(store.dispatch)
19 
20     return {
21       ...store,
22       dispatch
23     }
24   }

这个是 redux 的 applyMiddleware 源码,这里重点要讲的是 第 17、18 行,就两句话,实现了将传入的多个中间件套在一起,层层返回结果,最终丰富了 dispatch 。

首先背景知识: 我问我们用来丰富 dispatch 的中间件都是按照一定规律编写的,有固定传参顺序, 格式如下 const reduxMiddleware = ({dispatch, getState}[简化的store]) => (next[上一个中间件的dispatch方法]) => (action[实际派发的action对象]) => {} 每个中间件接收 getState 和 dispatch 作为参数,并返回一个函数,该函数会被传入下一个中间件的 dispatch 方法,并返回一个接收 action 的新函数,最后在调用一次,传入 action 得到结果。

知道中间件编写的固定格式之后, 首先 17 行,使用 map 遍历 传进来的 多个 middlewares ,给每个中间件都传入参数 middlewareAPI,也就是最后的 chain 是这样的:

1 chain = [
 2       function middlewareCreator1(next) {
 3         // with getState, dispath
 4     
 5       },
 6       function middlewareCreator2(next) {
 7         // with getState, dispath
 8     
 9       },
10       ...
11 ]

middlewareAPI 是一个对象,这个对象是中间件所需要的第一个参数 ({dispatch, getState}[简化的store]) 。第17行的目的就是将每个中间件所需要的第一个参数预置了进去,这个时候每个中间件就会返回一个 需要 next 作为参数的函数,chain 就是由这些返回的函数而组成的一个数组。

之后执行了

dispatch = compose(...chain)(store.dispatch)

compose 是在第一行 引入的,它是用来组合函数,也就是将传入的多个中间件套在一起。

咱们来看一下 compose 的源码,看看它是如何组合的:

1 export default function compose(...funcs) {
 2   // 如果什么都没有传,则直接返回 参数
 3   // return arg => arg 即
 4   // return function (arg) {
 5   //   return arg;
 6   // };
 7   if (funcs.length === 0) {
 8     return arg => arg
 9   }
10   // 如果funcs中只有一个中间件,那么就直接返回这个 中间件
11   if (funcs.length === 1) {
12     return funcs[0]
13   } 
14   
15   // reduce() 方法对累加器和数组中的每个元素(从左到右)应用一个函数,将其减少为单个值。
16   return funcs.reduce((a, b) => (...args) => a(b(...args)))
17 }

重点在第16行。

可以看到 redux 源码中在调用 compose 方法的时候 后面跟了两个括号,也就是调用了两次

第一次调用:

compose([a,b])

这个会返回

(...args) => a(b(...args))

把 a、b 换成刚才得到的 chain,即返回了一个需要 ...args 作为参数的函数:

(...args) => middlewareCreator1(middlewareCreator2(middlewareCreator3(...args)))

然后 第二次调用,

dispatch = compose(...chain)(store.dispatch)

将所需要的 ...args 传入了进去,即未丰富之前的 dispatch,最终得到:

middlewareCreator1(middlewareCreator2(middlewareCreator3(store.dispatch)))

也就是在 17 、18 行之后,咱们的 dispatch

dispatch = middlewareCreator1(middlewareCreator2(middlewareCreator3(store.dispatch)))

你还记得咱们刚才讲的中间件的固定格式中,在上面 对 middlewares 遍历之后,将每个中间件第一个参数预置进去,还需要调用两次,传入两个参数才会得到的真正的结果,而在 compose 之后,就只剩下一个参数了,你反应过来了吗?

chain 之后每个中间件需要一个 (next[上一个中间件的dispatch方法]) 作为第二个参数,而在执行完 compose 之后,多个中间件套在了一起,

当想要执行 middlewareCreator1 的时候,由于 middlewareCreator1 的执行依赖于内部参数的求值,所以会对内部参数进行调用,也就是执行 middlewareCreator2,而 middlewareCreator2 的执行依赖于内部 middlewareCreator3 的执行,所以最终将会先执行 middlewareCreator3 ,middlewareCreator3 传入了参数 store.dispatch 作为 (next[上一个中间件的dispatch方法]) 会返回一个需要 (action[实际派发的action对象]) 的函数,也就是丰富过 middlewareCreator3 的新的 dispatch 给 middlewareCreator2,这将作为 middlewareCreator2 的第二个参数 (next[上一个中间件的dispatch方法]),然后有了参数的 middlewareCreator2 得以顺利调用,这回返回一个需要 (action[实际派发的action对象]) 的函数,也就是丰富过 middlewareCreator3 和 middlewareCreator2 的新 dispatch 给 middlewareCreator1,这将作为 middlewareCreator1 的第二个参数 (next[上一个中间件的dispatch方法]),然后有了参数的 middlewareCreator1 得以顺利调用,最后就会返回一个 一个需要 (action[实际派发的action对象]) 的函数,也就是丰富过 middlewareCreator3 、 middlewareCreator2 和 middlewareCreator1 的新 dispatch ,这也就是最终的 dispatch,最后在我们使用的时候,常规调用 dispatch (action)的时候,将第三个参数传入了进来,进行了第三次调用,返回函数结果。

以上就是整个调用过程。 Middleware3 在接受 store.dispatch 作为 next 参数调用之后会返回一个函数,这个函数需要 action 作为第三个参数,即

action => {
     // .....中间件真正逻辑....
}

而在Middleware2 未得到 Middleware3 的返回结果即未被调用之前 Middleware2 是这样的

next => action => {
     // ...一些 Middleware2 代码...
     next(action);
     //...一些 Middleware2 代码...
}

然后在 Middleware3 调用之后, Middleware2 则接受了来自 Middleware3 返回的接受 action 为参数的函数 action => {} 作为 next 参数,然后进行调用,又会返回一个函数传给在下一个 等待 接受action为参数的函数 action => {} 作为下一个中间件的 next 参数,就这样层层组装,丰富了我们的 dispatch。

这个过程再想一下,其实一开始 通过 compose 的第一次调用依次嵌套起来,然后又通过第二次调用,将嵌套的函数从内到外反向调用,生成一个新的 dispatch,而这里面就用到了两次 柯里化和箭头函数的结合,第一次在 生成chain和调用 compose 方法,第二次在中间件本身(const reduxMiddleware = ({dispatch, getState}[简化的store]) => (next[上一个中间件的dispatch方法]) => (action[实际派发的action对象]) => {})。

而实现这样的逻辑的核心代码,就两行。

JS中的柯里化(currying)

箭头函数MDN

高阶箭头函数

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复