编程语言中的闭包( Closure )不同于其他数学领域(例如集合论)中的闭包,它来源于函数式语言,但早已不局限在函数式语言中。在实现了闭包特性的各种语言中,具体的特性会有细微的差异,但总体特征是一致的。本文集中讨论 js 语言中的闭包。
讨论闭包的文章、书籍已经有很多,其中不乏有准确而精辟的论述(当然重点不明、似是而非的论述也不少),然而对闭包持有误解的人依然相当多。因此本文会首先用相对较少的篇幅来讲述 js 闭包的定义,而将主要篇幅放在对于常见认识误区的纠正、以及闭包的主要应用方式上。
js 闭包的定义
简单来说,闭包就是保留了对自由变量的引用的函数(在某些语言中,闭包的单位也可以是代码块)。与面向对象方式使用类属性来保留数据不同,闭包是通过词法作用域来保留对于变量的引用的。
词法作用域的最基本特性是:在嵌套的作用域中,某个作用域可以访问本层定义的变量,也可以访问外层的变量,但是不能访问内层的变量。
对于 js 语言来说,最常见的作用域层级就是 var 声明所对应的函数级别作用域。当然也存在其余类型的作用域,例如 try/catch 作用域、 eval 作用域、 ES6 的 let/const 声明的代码块级别作用域等,但为了简化问题,姑且只讨论函数级别的作用域。
现在来考虑一下对自由变量的引用可以在什么层次上实现。首先,由于外层作用域无法访问内层作用域的变量,因此自由变量肯定不会是内层变量。其次,对于本层定义的变量来说,函数执行完毕后,这些变量就会被销毁,留待垃圾回收,因此本层的变量不具备保留引用的特性(先假设函数内部未嵌套其他函数,有嵌套的情况可以等概念理清后再回来讨论)。所以最后的结论已经很明确,保留引用的自由变量只能是外层的变量。
因此,闭包是保留了对外层自由变量的引用的函数。用轮子哥的话来说,闭包就是封闭外部状态的函数,此处借一张图:
如何保留对外层变量的引用?或者说,如何封闭外部状态?这要求函数能以某种方式被持久化保留。
来个例子:
function example(){
var count = 0;
return function(){
return ++count;
}
}
var getCount = example();
console.log(getCount()); // 输出 1
console.log(getCount()); // 输出 2
console.log(getCount()); // 输出 3
在这个例子中,调用 example() 函数时,内层的匿名函数被作为返回值赋予了外部的 getCount 变量。由于内层的匿名函数需要使用外层的 count 变量,因此外层变量 count 就被匿名函数保留了引用。而每次调用 getCount() , count 变量的值都会被加一。
在这个例子中,内层的匿名函数以及它所保留的对于外层变量 count 的引用就构成了闭包。
实现内层函数的持久化有很多种方式,除了上面将内层函数作为返回值的方式之外,还有:
function example2(obj){
var count = 0;
function inner(){
console.log(++count);
}
// 使用 dom0 级方式绑定事件处理函数
document.body.onclick = inner;
// 使用 dom2 级方式绑定事件处理函数
document.body.addEventListener('click', inner);
// 作为某些异步处理方法的回调函数
setTimeout(inner, 1000);
// 将内层函数赋予全局对象
window.inner = inner;
// 将内层函数绑定为传入对象的成员
obj.inner = inner;
// 返回一个对象,将内层函数作为对象的成员
return {
inner: inner
}
}
var obj1 = {};
var obj2 = example2(obj1);
以上随意列举了 6 种方式,其中 dom2 级方式是异步回调函数方式的一个特例,关于异步回调函数最常见的用例还包括 nodejs 中异步方法的回调函数。
如果内层函数没有用任何方式持久化,那么它也无法保留对于外层变量的引用。例如:
function noClosure(){
var count = 0;
function inner(){
console.log(++count);
}
inner();
}
noClosure(); // 输出 1
noClosure(); // 输出 1
noClosure(); // 输出 1
由于这个例子的 inner() 没有被持久化,调用时只能直接调用外层函数 noClosure() 。此时内层的 inner() 每次被调用后就结束了生命周期,相关的作用域也被销毁,因此 count 变量也不会被保留引用,这样每次调用 noClosure() 都只会输出 1 。
由此可以进一步总结闭包的概念:
闭包是以某种方式被持久化、并保留了对外层自由变量的引用的函数。实际的闭包指的是被持久化的函数以及它所保留的对外层变量的引用的组合。
现在可以回头来看前面的问题了:为什么同层变量不可以用来保留引用?因为一旦函数执行结束,它所定义的变量就完成了生命周期、应当被销毁。如果因为该函数内层嵌套有其他函数,并且内层函数被持久化,导致本层变量被保留了引用,例如第一个 example() ,那么此时的闭包是内层匿名函数与它所引用的 example() 函数作用域变量的组合,对于匿名函数来说,所引用的变量仍然是外层变量,而 example() 函数并不是闭包。
js 的闭包特性之所以成立,一是它允许函数嵌套,二是变量拥有多级作用域(以函数级别作用域为主),第三就是函数在 js 语言中是一等公民,可以当做值来使用。在 example2() 的例子中,那些持久化方式都是直接将 inner 函数作为值来使用,包括绑定到对象属性上、作为其他函数的参数等,而不是在 inner 函数后面用括号执行它。
《js 高级程序设计》的问题
《js 高级程序设计》是一本经典书籍,但这不代表它没有缺陷。具体到闭包概念上,此书对于闭包的讲述基本是正确的,但是它所给出的闭包定义却存在很大问题:
闭包是指有权访问另一个函数作用域中的变量的函数。
-
“另一个函数”指代不明。函数能够访问外层函数的变量,但不能访问它所嵌套的内层函数的变量,也不能访问同级函数的变量,更不能访问其他没有层级嵌套关系的函数的变量。因此,“另一个函数”应当修正为“外层函数”。
-
“有权访问”的说法没有意义。在 js 的嵌套函数中,内层函数原本就可以访问外层函数的变量,这是通用特性。但嵌套函数并不代表就是闭包,如果内层函数没有被持久化,那么它对于外层变量的访问就是一次性的,并不会在外层函数执行完毕后还保留着对外层变量的引用。
-
定义中没有体现要如何产生出实际的闭包。如前面所述,这要用某种手段让内层函数持久化。
常见的认识误区
js 的闭包概念其实并不复杂,一两句话定义加上一两个例子就可以说清楚。此处再把概念定义重写一次:
闭包是以某种方式被持久化、并保留了对外层自由变量的引用的函数。实际的闭包指的是被持久化的函数以及它所保留的对外层变量的引用的组合。
但在现实中,仍然发现很多人对闭包存在误解。纠正这些误解才是写本文的最主要原因。
1. 没有意识到闭包的存在。在某些闭包起作用的场合,误认为出现的问题是 js 语言设计的缺陷。
这方面最经典的例子就是在循环内使用回调函数:
function loop(){
for (var i = 0; i < 5; i++){
setTimeout(function(){
console.log(i);
}, (i + 1) * 500);
}
}
loop();
这个例子想必很多人都见过。代码执行结果是每过半秒钟输出一个数字 5 ,而不是像有些人预期那样依次输出 0 、 1 、 2 、 3 、 4 。如果认为这个结果是 js 语言设计的缺陷,就说明没有理解清楚闭包的概念。
这段代码为何会输出 5 个 5 ?循环执行了 5 次,每次都为 setTimeout() 函数指定了一个匿名函数作为其参数(也就是回调函数),而每个匿名函数都保留了对于外层 i 变量的引用,这样就产生了 5 个闭包。对于这 5 个闭包来说,它们的外层作用域是同一个,因为此例中只调用了一次 loop() ,这样 5 个闭包就对同一个变量 i 进行了引用,可以认为这种引用是共享的。在循环的过程中,当 i 的值自增到 5 时就不再满足循环条件,循环得以结束。这样对于 5 个闭包的匿名函数来说,它们所引用的变量 i 的值就都是 5 。
因此,循环中嵌套函数的问题恰恰就是 js 闭包特性的一个典型范例。对于闭包特性来说,它既有优点也有缺点,如果没有掌握清楚概念、使用了错误的代码编写方式,就会受到缺点的困扰,但这与语言本身的缺陷不是一回事。如果说有缺陷的话,在这个例子中体现出来的语言缺陷并不是闭包特性的问题,而是 js 中长期只有 var 变量声明方式的问题,缺少代码块级别的作用域。在 ES6 中,可以使用 let 声明来规避这种缺陷。
2. 认为闭包可以隔绝外层变量。
继续前面的例子。对于循环内的函数,解决方案之一如下:
function loop(){
for (var i = 0; i < 5; i++){
(function(i){
setTimeout(function(){
console.log(i);
}, (i + 1) * 500);
})(i);
}
}
loop();
也可以写为:
function loop(){
for (var i = 0; i < 5; i++){
setTimeout((function(i){
return function(){
console.log(i);
}
})(i), (i + 1) * 500);
}
}
loop();
有些人认为这里是使用闭包屏蔽了外层的 i 变量。这种说法的错误在于:
- 没有意识到在使用 IIFE (立即调用函数表达式)之前,循环内已经存在闭包了。
- 由闭包的定义就可以看出,能够保留对于外层变量的引用才是闭包的特征,而屏蔽变量的说法是与这种特征背道而驰的。
- 对于第一个修改方式来说,新产生的闭包是最内层的匿名函数(有 5 个),而不是有些人认为的 IIFE 。
如图所示,图中红色区域是闭包函数,而黄色的变量 i 则是它所保留引用的外层变量。
这段代码的作用为: IIFE 使用外层变量 i 作为参数(蓝色部分)进行了立即调用;外层 i 变量的值被复制给了内层函数的形参 i (黄色部分),注意 js 的函数传参是采用值复制的方式而不是引用;最后最内层的匿名函数使用了变量 i ,它所用的是黄色部分的变量 i 的引用,而该变量已经在循环迭代过程中通过值传递被依次设为了 0 、 1 、 2 、 3 、 4 。最终, 5 个最内层匿名函数就作为闭包分别持有了 loop() 函数 i 变量的一个副本,相互之间不再共享对于同一变量的引用。
关于参数的值传递,可以用另一种方式对照一下:
function loop(){
function inner(i){
setTimeout(function(){
console.log(i);
}, (i + 1) * 500);
}
for (var i = 0; i < 5; i++){
inner(i);
}
}
loop();
也就是说,不使用 IIFE 也可以得到基本相同的效果。
此外,最外层 i 变量的值之所以被遮蔽,是因为内层函数也使用了变量名 i ,从而遮蔽了同名的外层变量。这是词法作用域的特性,只要作用域嵌套就会有这种特性,甚至对于顶层函数来说,它也可以用自身定义的变量遮蔽同名的全局变量。这与是否出现闭包是没有绝对关系的。
如果为内层变量使用其他的变量名:
function loop(){
for (var i = 0; i < 5; i++){
(function(j){
setTimeout(function(){
console.log(i, j);
}, (j + 1) * 500);
})(i);
}
}
loop();
输出结果为:
5 0
5 1
5 2
5 3
5 4
可以看到对于外层的变量 i 来说,它仍然是被各个闭包共享的。
注:稍微多说几句,使用 setTimeout 来演示在循环内使用函数的效果,是因为比较方便,但实际上这并不是最好的例子,因为 setTimeout 、 setInterval 本身有其他手段可以解决这个问题。
function loop(){
for (var i = 0; i < 5; i++){
setTimeout(function(i){
console.log(i);
}, (i + 1) * 500, i);
}
}
loop();
可以看到, setTimeout 不仅仅可以使用两个参数,如果有更多参数,这些参数的值会被传递给回调函数。
3. 混淆闭包与 IIFE (立即调用函数表达式)。
这种误解是极其极其常见的。从外观上来看, IIFE 使用一对括号包住了匿名函数,最常见的有
(function(){ /* 内部代码 */})()
以及
(function(){ /* 内部代码 */}())
这两种形式(还有使用单目运算符的形式,此处不展开)。望文生义来说,这不就是“闭包”么?然而这就是一种认识偏差。以前面的例子来说:
(function(i){
setTimeout(function(){
console.log(i);
}, (i + 1) * 500);
})(i);
实际的闭包是最内层的匿名函数、以及它所保留引用的外层变量 i (外层匿名函数的形参),并不是 IIFE 自身。
也许有人会认为,这里闭包的函数以及引用变量恰恰是被封闭在外层的匿名函数内,说外层匿名函数是闭包,貌似也没有太大问题吧?然而这种观点经不起推敲:
在前面将中间层变量改名为 j 的例子中,闭包可以同时访问到 i 与 j 两个外层变量,而变量 i 的作用域已经明显超出了 IIFE 的范围。
此外,在现代浏览器中,引擎会检查闭包函数,只会保留函数实际会用到的外层变量,而会销毁无关的外层变量。从这个意义上来说,在出现闭包时,外层变量并不是全都被保留的,因此将外层函数视为闭包是不正确的看法。
IIFE 与闭包有着千丝万缕的联系。闭包产生的机制之一是词法作用域,而 js 最常见的词法作用域就是函数级别的作用域。 IIFE 作为一个函数,自然也会制造出一层作用域,因此它与闭包的产生经常是有关联的。
但是 IIFE 也可以用于完全与闭包无关的场合,例如:
var x = (function(a, b, opts){
var result, temp1, temp2;
/* 一些复杂的同步运算…… */
return result;
})(arg1, arg2, options);
这种应用方式相对少见,但也有其作用。函数内部执行的是一些复杂运算,在运算完成后将结果返回出来。如果这个复杂运算过程是一次性的,就可以考虑使用这种方式。其优点是:不用定义一个具名函数,更不用将运算过程中所用到的中间变量定义在全局上,避免污染全局作用域(或者在其他函数内部使用这种方式时,避免污染目标函数的作用域)。
对于 IIFE 的这种运用方式显然是与闭包无关的,因为它的执行是一次性的,执行完毕就销毁作用域,不会保留对于自由变量的引用。
4. 认为闭包保留的外层变量是对变量进行了复制。
有这样一种说法:可以把闭包看成是将关联的外层变量作为属性绑定到内层函数上。
如果某个作用域内部只产生了一个闭包,这种说法貌似没有什么问题,“对于外部变量的引用”与“将外部变量复制为属性绑定到函数上”,从效果上来看好像区别不大。
然而若某个作用域内部有多个闭包呢?就像前面循环的例子,乍一看代码似乎只有一个闭包,但因为循环迭代了 5 次,实际上存在 5 个闭包。如果外层变量是复制为闭包函数的属性的话,那么每个闭包函数应当拥有外层变量的不同副本,它们的值应该相互不同,但事实却不是这样。要知道, js 的函数传参、对象属性赋值、变量之间的相互赋值,使用的都是值传递的方式,不仅是简单类型使用值传递(值复制),复杂类型(纯对象以及数组、正则表达式等等各种特殊对象)也是使用值传递的。但是对于复杂类型来说,“值传递”这个名词容易误导新手,因此也有“引用复制( reference-copy )”传递的说法,此处不展开。
在值传递的背景下,若将变量复制为内层函数的属性,显然是无法保证多个闭包保留对同一个变量的引用的。实际上这里确实存在对象与属性,但不是直接将外层变量作为属性,而是有特殊的作用域对象( [[scope]] ),变量作为作用域对象的属性存在。
闭包的主要应用
闭包具有保留外层变量引用的特性,以下应用方式都是基于这种特性的。其中有些可能比较相近,甚至作用有所重叠,但主要是从不同角度去思考,因此分点列出。
1. 保留辅助变量
对本文的第一个例子稍微扩展一下:
function example(){
var count = 0;
return function(){
return ++count;
}
}
var getCount_1 = example();
var getCount_2 = example();
console.log(getCount_1()); // 输出 1
console.log(getCount_1()); // 输出 2
console.log(getCount_1()); // 输出 3
console.log(getCount_2()); // 输出 1
console.log(getCount_2()); // 输出 2
这个例子对 example() 进行了两次调用,得到了两个闭包,它们分别拥有各自的自由变量 count ,相互之间不干扰。
虽然闭包的函数指的是内层函数,但作用域却是对外层函数调用而产生的。
2. 保留预设值
主要指对于初始化时传入的参数,将其作为外层自由变量保存,并且在初始化完成后不再修改这些自由变量。典型的用例可以参照 Function.prototype.bind() 方法:
Function.prototype.bind = function(that){
var args = Array.prototype.slice.call(arguments, 1);
var func = this;
return function(){
var allArgs = Array.prototype.concat.apply(args, arguments);
// 上面的写法也可以改为:
// var allArgs = args.concat(Array.prototype.slice.call(arguments));
func.apply(that, allArgs);
};
};
此处只是对 Function.prototype.bind() 的作用进行了简单的模拟,而完整的 polyfill 还需要考虑参数合法性,以及内层函数作为构造函数时的表现,需要做更多的处理工作。但即使是完整的 polyfill ,与 ES5 原生的 bind() 方法还是有区别的。此处为了举例只写了一个简化的版本。
此处的闭包是内层函数以及它所引用的 that 、 args 与 func 变量。由于 bind() 是通过原型链在某个函数上被调用的,因此外层的 this 也就代表被调用的函数自身,此处使用一个变量 func 保存了对于外层 this 的引用,以便在内层函数中可以使用它。 that 代表当内层函数被调用时,其内部调用 func 函数(也就是原函数)时需要指定的 this 值。而 args 代表 bind() 方法调用时传入的更多参数,也就是允许绑定更多的预设值。
简单的用例:
var obj1 = {
name: 'first'
};
var obj2 = {
name: 'second'
};
function output(a, b, c){
console.log(this.name + ': ' + (a + b + c));
}
var output1 = output.bind(obj1);
output1(10, 34, 18); // 输出 first: 62
var output2 = output.bind(obj2, 100, 10);
output2(6); // 输出 second: 116
output1.call(obj2, 1, 2, 3); // 输出 first: 6
output1 绑定到了对象 obj1 ,其内部的 this 指向 obj1 。而 output2 不仅绑定了对象 obj2 作为内部的 this ,同时还绑定了两个参数 100 与 10 ,因此 output2 在调用时只要传入一个参数即可。后面 output1.call(obj2, 1, 2, 3) 的调用, call() 虽然传入了对象 obj2 ,但 bind() 方法内部的闭包却没有使用这个对象,而是使用了之前绑定时预设的对象 obj1 。
对于更多参数的绑定,一般被称为“柯里化”( currying ),但它其实与数学意义上的柯里化不同。柯里化原本指的是将一个多参数的函数变换为一系列单参数的函数,也就是每次削减(绑定)一个参数。现在编程语言中经常提到的柯里化,实际上混合了“部分施用”( partial application )或“偏函数”( partial functions )的概念。
绑定了预设值的闭包,可以作为参数传递给其他函数,例如:
function setBase(fn, base){
return function(value){
return fn(base, value);
}
}
function gt(base, value) {
return value >= base;
}
var arr = [10, 510, -80, 170, 55];
var gt100 = setBase(gt, 100);
console.log(arr.filter(gt100)); // 输出 [510, 170]
var gt50 = setBase(gt, 50);
console.log(arr.filter(gt50)); // 输出 [510, 170, 55]
3. 手动指定 this 值
这方面应用实际上在上面 bind() 方法中已经展示过了。 this 在 js 中是一个特殊的对象,对于同一个函数来说,如果调用函数的方式不同,就有可能造成函数内部 this 值不同。因此在有些场合下,为了保证 this 值可控,会使用闭包来实现程序意图:
var obj = {
level: 6,
say: function(){
console.log(this.level);
},
lazySay: function(){
setTimeout(this.say, 1000);
}
}
obj.lazySay(); // 输出 undefined
由于 setTimeout 的回调函数中的 this 默认会指向全局对象(浏览器中是 window ),无论是否在严格模式下。如果全局对象上不存在 level 属性,那么输出结果自然就是 undefined 。
上述代码可以用几种方式进行改造:
lazySay: function(){
setTimeout(this.say.bind(this), 1000);
}
或者:
lazySay: function(){
var self = this;
setTimeout(function(){
self.say();
}, 1000);
}
也可以使用 setTimeout() 的更多参数,或者 ES6 的箭头函数。
4.模拟对象的私有属性
js 语言中不存在真正的“类”概念( ES6 的 class 仅仅是个语法糖),只存在对象。而对于对象来说,目前还不存在私有属性( private 或者 protected ),即无法限定一个属性只能从对象实例内部访问、无法从外部访问。
模拟对象的私有属性有几种方式,而使用闭包是其中的关键。例如对于前面使用 count 变量的例子来说,这个变量只能从闭包内部访问,不能从外部代码访问,具备私有特性。
使用闭包模拟对象的私有属性,缺点之一是由于保留了作用域,因此性能会有些许的下降。不过更大的问题还出现在需要从外部给对象增加属性或者方法时:
var Base = (function(){
var _prop1 = 15;
var _name;
function Base(name){
_name = name;
}
Base.prototype.say = function(){
console.log(_name + ': ' + _prop1);
}
return Base;
})();
var instance = new Base('test');
instance.say(); // 输出 test: 15
这个例子内层的 Base() 构造函数将传入的参数赋值给外层变量 _name ,而 say() 方法则引用了 _prop1 与 _name 两个外层变量。
此时如果从外部给 instance 添加方法,则可能会遇到问题:
instance.outerSay = function(){
console.log('outerSay: ', _prop1);
}
instance.outerSay();
结果获得了一个 _prop1 未定义的错误。
这是因为 js 虽然是动态语言,但它的作用域却是词法作用域,是静态的。除了 this 这个特殊对象外,对于其余变量的使用都要看代码定义的位置。 outerSay() 这个方法定义在 Base 代码区域之外,因此它无法访问到 Base 内部的 _prop1 变量。
因此,使用闭包来模拟对象的私有属性,如果存在从外部扩展对象的需求,或者需要对对象进行原型链继承,那么就会出现难题。
5. 改写小型对象
这种应用与前一个其实非常接近,但思维角度稍微有点不同。
假设有这么一个对象构造器:
function Util(min, max){
this.min = min;
this.max = max;
}
Util.prototype.check = function(num){
return num >= this.min && num <= this.max;
}
var util1 = new Util(10, 70);
console.log(util1.check(18));
可以用前面提到的模拟私有属性的方式来改造它。
function Util(min, max){
function check(num){
return num >= min && num <= max;
}
return {
check: check
};
}
var util1 = Util(10, 70);
console.log(util1.check(18));
这样改造之后,一是避免了对象的 min 与 max 属性被从外部进行篡改,二是在使用 uglify 之类的工具来混淆代码时会获得更小的文件(不过对于网站使用的 gzip 来说,尺寸区别可能就不那么大了),也可以稍微隐藏代码的真实逻辑。
如果小型对象没有从外部添加方法、属性的需求,也不需要对其进行继承,那么用闭包来改造小型对象是一种可行的方案。
6. 使用回调函数
前面的例子基本上只涉及了如何将函数作为回调函数传入,没有涉及如何使用所传入的回调函数。实际上,使用回调函数基本也是用闭包的方式,例如:
function doRead(filepath, cb){
fs.readFile(filepath, function(err, data){
if (err) {
console.log('Error: ', err.message);
} else if (typeof cb === 'function') {
cb(data);
}
});
}
doRead('/dict.txt', function(data){
console.log(data.match(/\bimportant\b/gi).length);
});
这段代码读取 dict.txt 文件的内容,并统计其中单词 important 的出现次数。当然,文件内容一次性读出并不是好的实践,此处只是简单举个例子。
下方对于 doRead 的调用,传入了一个匿名函数作为回调函数。而在 doRead() 内的闭包函数( function(err, data){ } )中,保留了对外层变量 cb 的引用,并且在异步操作成功完成后调用了 cb 函数,也就是前面提到的匿名函数。
由于 js 语言中函数一等公民的特性,函数自然也可以作为外层自由变量被保留引用。实际上,按 js 语言的规范来说,函数也是一种特殊的对象,可以使用 instanceof Object 检测来验证这种关系(不过引擎的具体实现可能有些不同,在 V8 中 Function() 反而是 Object() 的基类,但修改了 constructor 属性以及 prototype 以保证表面行为符合规范)。
7. 实现模块化
js 语言原先没有 模块/包 的概念,后来才出现了多种有关模块的规范。这里只简单提一下 requirejs 的模块。
requirejs 的模块对于闭包的使用比较明显,例如:
define(['./fix'], function(fix){
return function(url) {
// 一些操作,其中引用了 fix
};
});
内层函数保留了对外层 fix 变量的引用。
作者:sagittarius-rev
链接:https://zhuanlan.zhihu.com/p/26899840