前言

以下JS高阶编程技巧均属于闭包的高级应用。 文章有点长,请耐心食用~

惰性函数思想

我们来举个例子证明JS中的惰性函数思想。

以单击响应事件为例,

  • DOM 0 级事件绑定方式为: 元素.onclick = function(){…}
  • DOM 2 级事件绑定方式(大多数)为: 元素.addEventListener("click",function(){…}),但这种方式不被IE6~8所支持,在低版本浏览器中采用这种方式: 元素.attachEvent("onclick",function(){…})

我们希望基于DOM2进行事件绑定,并兼容所有浏览器。 自然而然需要进行判断:

/* observerEvent:基于DOM2进行事件绑定
* @params:
* element: 需要绑定事件的元素
* type: 绑定的事件类型
* func: 事件响应函数
* @return: undefined
*/
function observerEvent(element,type,func){
if(element.addEventListener){
element.addEventListener(type, func); // 如果有这个就用这个
}else if(element.attachEvent){
element.attachEvent("on" + type, func); // 不行的话有这个用它也可以
}else{
element["on" + type] = func; // 再不行就得用DOM0事件绑定方法
}
}

这个方法可以实现功能,但如果多次执行,每一次都要判断浏览器的兼容性,是在有点麻烦。我们希望在第一次执行方法的时候做一次判断,以后不要重复判断了。

function observerEvent(element,type,func){
if(element.addEventListener){
observerEvent = function(element,type,func){
element.addEventListener(type, func);
}
}else if(element.attachEvent){
observerEvent = function(element,type,func){
element.attachEvent("on" + type, func);
}
}else{
observerEvent = function(element,type,func){
element["on" + type] = func;
}
}
observerEvent(element,type,func);
}

以上代码主要做了两点修改:

函数第一次执行,无论进入哪一个判断,大函数形成的私有上下文都会产生一个新的函数堆,并且将这个堆的地址赋给全局的observerEvent变量,形成不被销毁的上下文,从而形成闭包。observerEvent函数完成了重构,新指向的这个函数是不需要做兼容判断的。
添加了observerEvent(element,type,func)这句代码。因为在函数第一次执行时,函数重构只是新建了函数堆、更改了函数指向,并没有执行函数,因此需要加一句代码,手动执行重构之后的方法,实现事件绑定。

单例设计模式

单例设计模式是最早的模块化开发思想的体现。(框架开发下就不需要了) 比如,2人合作开发一个项目,A负责module1的功能开发,B负责module2的功能开发。两人为了防止变量冲突,会将自己写的代码放在一个闭包里,闭包内所有的变量都是私有的:

(function module1{
let index = 0;
function queryData(){};
})()

(function module2{
let index = 0;
function queryData(){};
})()

A封装了一个非常好用的方法getElement,B希望”copy”过来供自己所用 (这里我们不讨论ctrl+c/ctrl+v的这种简单粗暴的方法),但是正常情况下,B无法调用A闭包中的方法。 有两种方法可以解决这个问题:

  1. B对A说,你把我想借鉴的那个方法暴露到全局上吧,这样我也能用啦。 不得不说,如果想向全局暴露的方法非常多,也会引起变量冲突,与直接写在全局没啥区别。
    (function module1{
    let index = 0;
    function queryData(){};
    function getElement(){};
    window.getElement = getElement;
    })()

    (function module2{
    let index = 0;
    function queryData(){};
    window.getElement();
    })()
  2. 通过return一个对象的方式,把一些方法暴露出去。
    let moduleA = (function module1{
    let index = 0;
    function queryData(){};
    function getElement(){};
    return{
    getElement // 是getElement: getElement的简写(属性名和属性值相同可简写)
    }
    })()

    let moduleB = (function module2{
    let index = 0;
    function queryData(){};
    moduleA.getElement(); // 模块2通过这种方式调用模块1中暴露出来的方法
    })()

通过这种方式,我们暴露在全局的只有moduleA和moduleB这样的命名空间名。将描述相同事务(相同版块)中的属性&方法归拢到相同的命名空间下,实现分组管理,既不会污染全局变量空间、引起变量冲突,又可以将一些自己私有的方法暴露出来,以便其他模块调用。

除此之外,我们还会在return的对象中添加一个init属性,该属性值是一个函数。在单例模式设计的基础上,再加一个命令模式。init作为当前模块业务的入口:

let moduleA = (function module1{
let index = 0;
function queryData(){};
function getElement(){};
function bindHTML(){};
function handleEvent(){};
return{
init(){
getElement();
bindHTML();
handleEvent();
}
}
})()
moduleA.init();

调用这个模块方法的时候,只需要执行init方法就可以了,我们在init方法中,根据业务需求,把编写的方法按照顺序依次调用执行即可。

柯里化函数思想

介绍:这是一个应用很普遍的思想~ 它的核心概念——“预先存储&处理”; 它的常见形式——大函数传入一些参数,返回一个小函数对传进来的参数进行处理。

拿一道题来解释一下:

// 写一个函数fn实现如下功能:
let res = fn(1,2)(3);
console.log(res); => 6(1+2+3)

首先分析一波:函数fn(1,2)执行之后还可以继续执行并传入参数3,说明函数fn执行返回了一个函数,并且这个函数还有一个形参。 我们暂且认为参数是固定个数的:


function fn(x,y){
return function(z){
return x+y+z;
}
}
let res = fn(1,2)(3);
console.log(res);

可以看出,第一次执行函数时,fn形成的私有上下文创建的一个函数堆被全局变量res所引用,因此这个私有上下文不能被销毁,它的私有变量x、y于是得以保存下来,在执行小函数时,沿着作用域链找到上级上下文中的x、y,并进行后续处理。这就是柯里化函数思想的具体体现。

其他应用: Function.prototype.bind中预先处理this redux、react-redux源码中大量应用了柯里化函数思想

compose函数——实现函数调用扁平化

const add1 = (x) => x+1;
const mul3 = (x) => x*3;
const div2 = (x) => x/2;
let result = div2(mul3(add1(add1(0))));
console.log(result);

通过函数的嵌套调用,我们得到最后结果为3。但嵌套的函数数目较多时代码可读性非常差,于是我们想要一个这样的函数compose(div2,mul3,add1,add1)(0)实现同样的功能,实现函数调用的扁平化。

场景:一个函数的执行结果作为实参,传递给下一个函数执行。 前提:这些函数只接收一个参数。 f(g(h(x)))------>compose(f,g,h)(x)/compose(h,g,f)(x),函数参数的顺序可以自己指定。 那compose函数该怎么写呢?

首先我们了解一下数组迭代方法reduce的使用方法

数组的reduce方法

let arr = [10,20,30,40];
arr.reduce((N,item)=>{
console.log(N,item);
})
// 输出结果为:
// 10 20
// undefined 30
// undefined 40

reduce方法可传两个参数:

  • 第一个参数为函数(必传):即数组迭代到每一项时执行的回调函数,函数有两个形参N,item。其中item为数组迭代时的当前项。
  • 第二个参数为基本类型值(选传)

只传一个参数时(上例),N的初始值为数组arr第一项,item从第二项开始遍历;之后N的值为上一次函数返回的结果(return的值)。由于上例中函数没有返回值,因此N的值为undefined。

传两个参数时,N的初始值为第二个参数值,item则从第一项开始遍历;之后N的值为上一次函数返回的结果(return的值)。

总结:

  • N为上一次函数返回的结果,但其初始值取决于是否传第二个参数。
  • item为数组迭代时的当前项,迭代开始的起点取决于是否传第二个参数。
  • 原数组不变,结果以返回值的形式返回。

了解了reduce方法之后,我们再看compose函数该咋写,需要分类讨论:

function compose(...funcs){
return function anonymous(val){ // 既然可以连续执行,返回值就是一个函数
if(funcs.length === 0) return val; // 如果函数形参列表为空,就返回传入的值
if(funcs.length === 1) return funcs[0](val); // 如果只有一个函数,执行就可
return funcs.reverse().reduce((N,item)=>{ // 重点来了!解析在下面
return typeof N==="function" ? item(N(val)) : item(N);
})
}
}

当函数列表中有两个以上的函数执行时,需要用到reduce方法遍历funcs这个函数参数数组。

funcs数组是否需要反转reverse,取决于将数组扁平化之后函数参数的执行顺序,在这个例子中,compose(div2,mul3,add1,add1)(0)这个函数的执行顺序是从后向前的,因此需要进行反转。

上面代码中,reduce没有传第二个参数,我们来捋一下每轮迭代的结果:

N item 操作
N add1 add1(add1(0)) => 下一轮N item(N(val))
N mul3 mul3(add1(add1(0))) => 下一轮N item(N)
N div2 div2(mul3(add1(add1(0)))) => 下一轮N item(N)
N item(N)

于是诞生了这段代码:

// 重点来了!解析在下面
return funcs.reverse().reduce((N,item)=>{
return typeof N==="function" ? item(N(val)) : item(N);
})

实际还有更简单的写法:

return funcs.reverse().reduce((N,item)=>{  
return item(N)
},val)

由于传了第二个参数val,因此N的初始值为val,只需每轮迭代执行item(N),然后将结果赋值给N (此处省略xxxxxx轮循环) 就可了!

参考链接

JS高阶编程技巧——惰性函数&单例设计模式、柯里化函数思想、compose函数