Skip to main content

第三章 闭包和高阶函数

虽然 JavaScript 是一门完整的面向对象的编程语言,但这门语言同时也拥有许多函数式语言的特性。

函数式语言的鼻祖是 LISP。

JavaScript 在设计之初参考了 LISP 两大方言之一的 Scheme,引入了 Lambda 表达式、闭包、高阶函数等特性。

使用这些特性,我们经常可以用一些灵活而巧妙的方式来编写 JavaScript 代码。

在 JavaScript 版本的设计模式中,许多模式都可以用闭包和高阶函数来实现。

3.1 闭包

当在函数中声明一个变量的时候,如果该变量前面没有带上关键字 var,这个变量就会成为全局变量,这当然是一种容易造成命名冲突的做法。

当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。

变量的搜索是从内到外而非从外到内的。

对于全局变量来说,全局变量的生存周期当然是永久的,除非我们主动销毁这个全局变量。

而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁。

var func = function () {
var a = 1;
return function () {
a++;
alert(a);
};
};

var f = func();
f(); // 输出:2
f(); // 输出:3
f(); // 输出:4
f(); // 输出:5

这是因为当执行 var f = func(); 时,f 返回了一个匿名函数的引用,它可以访问到 func() 被调用时产生的环境,而局部变量 a 一直处在这个环境里。

既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。

在这里产生了一个闭包结构,局部变量的生命看起来被延续了。

利用闭包我们可以完成许多奇妙的工作,下面介绍一个闭包的经典应用。

假设页面上有 5 个 div 节点,我们通过循环来给每个 div 绑定 onclick 事件,按照索引顺序,点击第 1 个 div 时弹出 0,点击第 2 个 div 时弹出 1,以此类推。 代码如下:

<html>
<body>
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<script>
var nodes = document.getElementsByTagName("div");
for (var i = 0, len = nodes.length; i < len; i++) {
nodes[i].onclick = function () {
alert(i);
};
}
</script>
</body>
</html>

测试这段代码就会发现,无论点击哪个 div,最后弹出的结果都是 5

这是因为 div 节点的 onclick 事件是被异步触发的,当事件被触发的时候,for 循环早已结束,此时变量 i 的值已经是 5,所以在 divonclick 事件函数中顺着作用域链从内到外查找变量 i 时,查找到的值总是 5

解决方法是在闭包的帮助下,把每次循环的 i 值都封闭起来。当在事件函数中顺着作用域链中从内到外查找变量 i 时,会先找到被封闭在闭包环境中的 i,如果有 5div,这里的 i 就分别是 0, 1, 2, 3, 4

for (var i = 0, len = nodes.length; i < len; i++) {
(function (i) {
nodes[i].onclick = function () {
console.log(i);
};
})(i);
}

根据同样的道理,我们还可以编写如下一段代码:

var Type = {};
for (var i = 0, type; (type = ["String", "Array", "Number"][i++]); ) {
(function (type) {
Type["is" + type] = function (obj) {
return Object.prototype.toString.call(obj) === "[object " + type + "]";
};
})(type);
}
Type.isArray([]); // 输出:true
Type.isString("str"); // 输出:true

在实际开发中,闭包的运用非常广泛。

  1. 封装变量

    闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”。

    假设有一个计算乘积的简单函数:

    var mult = function () {
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
    a = a * arguments[i];
    }
    return a;
    };

    mult 函数接受一些 number 类型的参数,并返回这些参数的乘积。

    现在我们觉得对于那些相同的参数来说,每次都进行计算是一种浪费,我们可以加入缓存机制来提高这个函数的性能:

    var cache = {};
    var mult = function () {
    var args = Array.prototype.join.call(arguments, ", ");
    if (cache[args]) {
    return cache[args];
    }
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
    a = a * arguments[i];
    }
    return (cache[args] = a);
    };
    alert(mult(1, 2, 3)); // 输出:6
    alert(mult(1, 2, 3)); // 输出:6

    我们看到 cache 这个变量仅仅在 mult 函数中被使用,与其让 cache 变量跟 mult 函数一起平行地暴露在全局作用域下,不如把它封闭在 mult 函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。 代码如下:

    var mult = (function () {
    var cache = {};
    return function () {
    var args = Array.prototype.join.call(arguments, ", ");
    if (args in cache) {
    return cache[args];
    }
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
    a = a * arguments[i];
    }
    return (cache[args] = a);
    };
    })();

    提炼函数是代码重构中的一种常见技巧。

    如果在一个大函数中有一些代码块能够独立出来,我们常常把这些代码块封装在独立的小函数里面。

    独立出来的小函数有助于代码复用,如果这些小函数有一个良好的命名,它们本身也起到了注释的作用。

    如果这些小函数不需要在程序的其他地方使用,最好是把它们用闭包封闭起来。代码如下:

    var mult = (function () {
    var cache = {};
    var calculate = function () {
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
    a = a * arguments[i];
    }
    return a;
    };
    return function () {
    var args = Array.prototype.join.call(arguments, ", ");
    if (args in cache) {
    return cache[args];
    }
    return (cache[args] = calculate.apply(null, arguments));
    };
    })();
    mult(1, 2, 3); // 6
  2. 延续局部变量的寿命

    img 对象经常用于进行数据上报,如下所示: ​​​​

    var report = function (src) {
    var img = new Image();
    img.src = src;
    };
    report("http://xxx.com/getUserInfo");

    但是通过查询后台的记录我们得知,因为一些低版本浏览器的实现存在 bug,在这些浏览器下使用 report 函数进行数据上报会丢失 30% 左右的数据,也就是说,report 函数并不是每一次都成功发起了 HTTP 请求。

    丢失数据的原因是 imgreport 函数中的局部变量,当 report 函数的调用结束后,img 局部变量随即被销毁,而此时或许还没来得及发出 HTTP 请求,所以此次请求就会丢失掉。

    现在我们把 img 变量用闭包封闭起来,便能解决请求丢失的问题:

    var report = (function () {
    var imgs = [];
    return function (src) {
    var img = new Image();
    imgs.push(img);
    img.src = src;
    };
    })();

闭包和面向对象设计

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。

对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。

通常用面向对象思想能实现的功能,用闭包也能实现。

反之亦然。在 JavaScript 语言的祖先 Scheme 语言中,甚至都没有提供面向对象的原生设计,但可以使用闭包来实现一个完整的面向对象系统。

var extent = function () {
var value = 0;
return {
call: function () {
value++;
console.log(value);
},
};
};

var extentObj = extent();
extentObj.call(); // 输出:1
extentObj.call(); // 输出:2
extentObj.call(); // 输出:3

如果换成面向对象的写法,就是:

var extent = {
value: 0,
call: function () {
this.value++;
console.log(this.value);
},
};

extent.call(); // 输出:1
extent.call(); // 输出:2
extent.call(); // 输出:3

或者:

var Extent = function () {
this.value = 0;
};

Extent.prototype.call = function () {
this.value++;
};

var extent = new Extent();
extent.call();
extent.call();
extent.call();
console.log(extent.value); // 输出:3

用面向对象的方式来编写一段命令模式的代码。

<html>
<body>
<button id="execute">点击我执行命令</button>
<button id="undo">点击我执行命令</button>
<script>
var Tv = {
open: function () {
console.log("打开电视机");
},
close: function () {
console.log("关上电视机");
},
};

var OpenTvCommand = function (receiver) {
this.receiver = receiver;
};

OpenTvCommand.prototype.execute = function () {
this.receiver.open(); // 执行命令,打开电视机
};

OpenTvCommand.prototype.undo = function () {
this.receiver.close(); // 撤销命令,关闭电视机
};

var setCommand = function (command) {
document.getElementById("execute").onclick = function () {
command.execute(); // 输出:打开电视机
};
document.getElementById("undo").onclick = function () {
command.undo(); // 输出:关闭电视机
};
};

setCommand(new OpenTvCommand(Tv));
</script>
</body>
</html>

命令模式的意图是把请求封装为对象,从而**分离请求的发起者和请求的接收者(执行者)**之间的耦合关系。

在 JavaScript 中,函数作为一等对象,本身就可以四处传递,用函数对象而不是普通对象来封装请求显得更加简单和自然。

如果需要往函数对象中预先植入命令的接收者,那么闭包可以完成这个工作。

在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;

而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中

var Tv = {
open: function () {
console.log("打开电视机");
},
close: function () {
console.log("关上电视机");
},
};

var createCommand = function (receiver) {
var execute = function () {
return receiver.open(); // 执行命令,打开电视机
};
var undo = function () {
return receiver.close(); // 执行命令,关闭电视机
};
return {
execute: execute,
undo: undo,
};
};

var setCommand = function (command) {
document.getElementById("execute").onclick = function () {
command.execute(); // 输出:打开电视机
};
document.getElementById("undo").onclick = function () {
command.undo(); // 输出:关闭电视机
};
};

setCommand(createCommand(Tv));

人们对闭包也有诸多误解。

一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。

局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。 从这个意义上看,闭包的确会使一些数据无法被及时销毁。

使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。

如果在将来需要回收这些变量,我们可以手动把这些变量设为 null

跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。

这本身并非闭包的问题,也并非 JavaScript 的问题。在 IE 浏览器中,由于 BOMDOM 中的对象是使用 C++ 以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略

在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。

如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。

将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。

当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

3.2 高阶函数

高阶函数是指至少满足下列条件之一的函数。

  • 函数可以作为参数被传递;

  • 函数可以作为返回值输出。

把函数当作参数传递,这代表我们可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。

其中一个重要应用场景就是常见的回调函数。

  1. 回调函数

    在 ajax 异步请求的应用中,回调函数的使用非常频繁。

    var getUserInfo = function (userId, callback) {
    $.ajax("http://xxx.com/getUserInfo?" + userId, function (data) {
    if (typeof callback === "function") {
    callback(data);
    }
    });
    };

    getUserInfo(13157, function (data) {
    alert(data.userName);
    });

    回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,“委托”给另外一个函数来执行。

    div.style.display = 'none' 的逻辑硬编码在 appendDiv 里显然是不合理的,appendDiv 未免有点个性化,成为了一个难以复用的函数,并不是每个人创建了节点之后就希望它们立刻被隐藏。

    于是我们把 div.style.display = 'none' 这行代码抽出来,用回调函数的形式传入 appendDiv 方法:

    var appendDiv = function (callback) {
    for (var i = 0; i < 100; i++) {
    var div = document.createElement("div");
    div.innerHTML = i;
    document.body.appendChild(div);
    if (typeof callback === "function") {
    callback(div);
    }
    }
    };
    appendDiv(function (node) {
    node.style.display = "none";
    });
  2. Array.prototype.sort

    Array.prototype.sort 接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。

    Array.prototype.sort 的使用可以看到,我们的目的是对数组进行排序,这是不变的部分;

    而使用什么规则去排序,则是可变的部分。

    把可变的部分封装在函数参数里,动态传入 Array.prototype.sort,使 Array.prototype.sort 方法成为了一个非常灵活的方法。

    // 从小到大排列
    [1, 4, 3].sort(function (a, b) {
    return a - b;
    });
    // 输出:[1, 3, 4]

    // 从大到小排列
    [1, 4, 3].sort(function (a, b) {
    return b - a;
    });
    // 输出:[4, 3, 1]

相比把函数当作参数传递,函数当作返回值输出的应用场景也许更多,也更能体现函数式编程的巧妙。

让函数继续返回一个可执行的函数,意味着运算过程是可延续的。

  1. 判断数据的类型

    更好的方式是用 Object.prototype.toString 来计算。

    不同的只是 Object.prototype.toString.call(obj) 返回的字符串。

    为了避免多余的代码,我们尝试把这些字符串作为参数提前值入 isType 函数。

    我们还可以用循环语句,来批量注册这些 isType 函数:

    var Type = {};
    for (var i = 0, type; (type = ["String", "Array", "Number"][i++]); ) {
    (function (type) {
    Type["is" + type] = function (obj) {
    return Object.prototype.toString.call(obj) === "[object " + type + "]";
    };
    })(type);
    }
    Type.isArray([]); // 输出:true
    Type.isString("str"); // 输出:true
  2. getSingle

    下面是一个单例模式的例子:

    var getSingle = function (fn) {
    var ret;
    return function () {
    return ret || (ret = fn.apply(this, arguments));
    };
    };

    这个高阶函数的例子,既把函数当作参数传递,又让函数执行后返回了另外一个函数。

    var getScript = getSingle(function () {
    return document.createElement("script");
    });
    // getScript 是使用单例模式封装的函数。

    var script1 = getScript();
    // 第一次调用 getScript() 时,会创建一个新的 <script> 元素,并将其缓存下来。
    var script2 = getScript();
    // 再次调用 getScript() 时,将直接返回缓存的 <script> 元素。

    alert(script1 === script2); // 输出:true

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。

把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。

这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。

在 JavaScript 这种动态语言中,AOP 的实现更加简单,这是 JavaScript 与生俱来的能力。

通常,在 JavaScript 中实现 AOP,都是指把一个函数“动态织入”到另外一个函数之中,具体的实现技术有很多。

本节我们通过扩展 Function.prototype 来做到这一点。

// 这段代码演示了函数的装饰器模式。
Function.prototype.before = function (beforefn) {
var self = this; // 保存原函数的引用
return function () {
beforefn.apply(this, arguments); // 执行新函数,修正 this
return self.apply(this, arguments); // 执行原函数
};
};
// before 方法用于在函数执行前执行另一个函数,

Function.prototype.after = function (afterfn) {
var self = this;
return function () {
var ret = self.apply(this, arguments);
afterfn.apply(this, arguments);
return ret;
};
};
// after 方法用于在函数执行后执行另一个函数。

var func = function () {
console.log(2);
};

func = func
.before(function () {
console.log(1);
})
.after(function () {
console.log(3);
});

func(); // 控制台输出 1, 2, 3

我们把负责打印数字 1 和打印数字 3 的两个函数通过 AOP 的方式动态植入 func 函数。

这种使用 AOP 的方式来给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式实现。

这种装饰者模式在实际开发中非常有用。

函数柯里化(function currying)

currying 的概念最早由俄国数学家 Moses Schönfinkel 发明,而后由著名的数理逻辑学家 Haskell Curry 将其丰富和发展,currying 由此得名。

currying 又称 部分求值

一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。

待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。

虽然下面的 cost 函数还不是一个 currying 函数的完整实现,但有助于我们了解其思想:

var cost = (function () {
var args = [];
return function () {
if (arguments.length === 0) {
var money = 0;
for (var i = 0, l = args.length; i < l; i++) {
money += args[i];
}
return money;
} else {
[].push.apply(args, arguments);
}
};
})();

cost(100); // 未真正求值
cost(200); // 未真正求值
cost(300); // 未真正求值
console.log(cost()); // 求值并输出:600

接下来我们编写一个通用的 function currying(){}

var currying = function (fn) {
var args = [];
return function () {
if (arguments.length === 0) {
return fn.apply(this, args);
} else {
[].push.apply(args, arguments);
return arguments.callee;
}
};
};

var cost = (function () {
var money = 0;
return function () {
for (var i = 0, l = arguments.length; i < l; i++) {
money += arguments[i];
}
return money;
};
})();

var cost = currying(cost); // 转化成 currying 函数
cost(100); // 未真正求值
cost(200); // 未真正求值
cost(300); // 未真正求值
alert(cost()); // 求值并输出:600

在 JavaScript 中,当我们调用对象的某个方法时,其实不用去关心该对象原本是否被设计为拥有这个方法,这是动态类型语言的特点,也是常说的鸭子类型思想。

同理,一个对象也未必只能使用它自身的方法。

我们常常让类数组对象去借用 Array.prototype 的方法,这是 callapply 最常见的应用场景之一。

(function () {
Array.prototype.push.call(arguments, 4); // arguments借用Array.prototype.push方法
console.log(arguments); // 输出:[1, 2, 3, 4]
})(1, 2, 3);

在我们的预期中,Array.prototype 上的方法原本只能用来操作 array 对象。

但用 callapply 可以把任意对象当作 this 传入某个方法,这样一来,方法中用到 this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。

uncurrying 的话题来自 JavaScript 之父 Brendan Eich 在 2011 年发表的一篇 Twitter。

以下代码是 uncurrying 的实现方式之一:

// uncurrying 方法返回一个新的函数,在这个函数内部,
// 使用 apply 方法将原始函数(self)与传入的对象(obj)和参数(arguments)绑定在一起,实现了非柯里化的效果。
Function.prototype.uncurrying = function () {
var self = this;
return function () {
var obj = Array.prototype.shift.call(arguments);
return self.apply(obj, arguments);
};
};

在类数组对象 arguments 借用 Array.prototype 的方法之前,先把 Array.prototype.push.call 这句代码转换为一个通用的 push 函数。

var push = Array.prototype.push.uncurrying();

(function () {
push(arguments, 4);
console.log(arguments); // 输出:[1, 2, 3, 4]
})(1, 2, 3);

通过 uncurrying 的方式,Array.prototype.push.call 变成了一个通用的 push 函数。

这样一来,push 函数的作用就跟 Array.prototype.push 一样了,同样不仅仅局限于只能操作 array 对象。

而对于使用者而言,调用 push 函数的方式也显得更加简洁和意图明了。