# 闭包
虽然 JavaScript 是一门完整的面向对象的编程语言,但这门语言同时也拥有许多函数式语言的特性。
函数式语言的鼻祖是 LISP, JavaScript 在设计之初参考了 LISP 两大方言之一的 Scheme,引入了 Lambda 表达式、闭包、高阶函数等特性。使用这些特性,我们经常可以用一些灵活而巧妙的方式来编写 JavaScript 代码。
对于 JavaScript 程序员来说,闭包(closure)是一个难懂又必须征服的概念。闭包的形成与变量的作用域以及变量的生存周期密切相关。下面我们先简单了解这两个知识点。
# 变量的作用域
变量的作用域,就是指变量的有效范围。我们最常谈到的是在函数中声明的变量作用域。
当在函数中声明一个变量的时候,如果该变量前面没有带上关键字 var,这个变量就会成为全局变量,这当然是一种容易造成命名冲突的做法。
另外一种情况是用 var 关键字在函数中声明变量,这时候的变量即是局部变量,只有在该函数内部才能访问到这个变量,在函数外面是访问不到的。代码如下:
var fn = function() {
var a = 1;
console.log(a); // 1
};
fn();
console.log(a); // Uncaught ReferenceError: a is not defined
2
3
4
5
6
在 JavaScript 中,函数可以用来创造函数作用域。此时的函数像一层半透明的玻璃,在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外而非从外到内的。
下面这段包含了嵌套函数的代码,也许能帮助我们加深对变量搜索过程的理解:
var a = 1;
var fn1 = function() {
var b = 2;
var fn2 = function() {
var c = 3;
console.log(b); // 2
console.log(a); // 1
};
fn2();
console.log(c); // Uncaught ReferenceError: a is not defined
};
fn1();
2
3
4
5
6
7
8
9
10
11
12
# 变量的生存周期
除了变量的作用域之外,另外一个跟闭包有关的概念是变量的生存周期。
对于全局变量来说,全局变量的生存周期当然是永久的,除非我们主动销毁这个全局变量。
而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁:
var fn = function() {
var a = 1; // 退出函数后局部变量a将被销毁
console.log(a);
};
fn();
2
3
4
5
6
现在来看看下面这段代码:
var fn = function() {
var a = 1;
return function() {
a++;
console.log(a);
};
};
var f = fn();
f(); // 2
f(); // 3
f(); // 4
f(); // 5
2
3
4
5
6
7
8
9
10
11
12
13
14
跟我们之前的推论相反,当退出函数后,局部变量 a 并没有消失,而是似乎一直在某个地方存活着。这是因为当执行 var f = fn();时,f 返回了一个匿名函数的引用,它可以访问到 fn()被调用时产生的环境,而局部变量 a 一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包
结构,局部变量的生命看起来被延续了。
利用闭包我们可以完成许多奇妙的工作,下面介绍一个闭包的经典应用。假设页面上有 5 个 div 节点,我们通过循环来给每个 div 绑定 onclick 事件,按照索引顺序,点击第 1 个 div 时弹出 0,点击第 2 个 div 时弹出 1,以此类推。代码如下:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
2
3
4
5
var nodes = document.getElementsByTagName("div");
for (var i = 0, len = nodes.length; i < len; i++) {
nodes[i].onclick = function() {
console.log(i);
};
}
2
3
4
5
6
测试这段代码就会发现,无论点击哪个 div,最后弹出的结果都是 5。这是因为 div 节点的 onclick 事件是被异步触发的,当事件被触发的时候,for 循环早已结束,此时变量 i 的值已经是 5,所以在 div 的 onclick 事件函数中顺着作用域链从内到外查找变量 i 时,查找到的值总是 5。
解决方法是在闭包的帮助下,把每次循环的 i 值都封闭起来。当在事件函数中顺着作用域链中从内到外查找变量 i 时,会先找到被封闭在闭包环境中的 i,如果有 5 个 div,这里的 i 就分别是 0,1,2,3,4:
var nodes = document.getElementsByTagName("div");
for (var i = 0, len = nodes.length; i < len; i++) {
(function(i) {
nodes[i].onclick = function() {
console.log(i);
};
})(i);
}
2
3
4
5
6
7
8
# 封装变量
闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”
。假设有一个计算乘积的简单函数:
var mult = function() {
var a = 1;
for (var i = 1; i < arguments.length; i++) {
a = a * arguments[i];
}
return a;
};
2
3
4
5
6
7
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 = 1; i < arguments.length; i++) {
a = a * arguments[i];
}
return (cache[args] = a);
};
2
3
4
5
6
7
8
9
10
11
12
13
我们看到 cache 这个变量仅仅在 mult 函数中被使用,与其让 cache 变量跟 mult 函数一起平行地暴露在全局作用域下,不如把它封闭在 mult 函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。代码如下:
var mult = (function() {
var cache = {};
return function() {
// 收集所有参数并以逗号隔开
var args = Array.prototype.join.call(arguments, ",");
if (cache[args]) {
return cache[args];
}
var a = 1;
for (var i = 1; i < arguments.length; i++) {
a = a * arguments[i];
}
return (cache[args] = a);
};
})();
mult(1, 2, 3); // 6
mult(1, 2, 3); // 6
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 闭包和面向对象设计
过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能实现。反之亦然。在 JavaScript 语言的祖先 Scheme 语言中,甚至都没有提供面向对象的原生设计,但可以使用闭包来实现一个完整的面向对象系统。
下面来看看这段跟闭包相关的代码:
var extent = function() {
var value = 0;
return {
call: function() {
value++;
console.log(value);
},
};
};
extent = extent();
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果换成面向对象的写法:
var extent = {
value: 0,
call: function() {
this.value++;
console.log(this.value);
},
};
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
或者:
var Extent = function() {
this.value = 0;
};
Extent.prototype.call = function() {
this.value++;
console.log(this.value);
};
var extent = new Extent();
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
12
上面换成 es6 代码:
class Extent {
constructor() {
this.value = 0;
}
call() {
this.value++;
console.log(this.value);
}
}
const extent = new Extent();
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
12
13
14
# 闭包与内存管理
闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露
,所以要尽量减少闭包的使用。
局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包的确会使一些数据无法被及时销毁。使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。如果在将来需要回收这些变量,我们可以手动把这些变量设为 null。
跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。在 IE 浏览器中,由于 BOM 和 DOM 中的对象是使用 C++以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。
同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
# 小结
在 JavaScript 开发中,闭包的应用极多,经常和高阶函数
一起搭配使用。
因为 JavaScript 这门语言的自身特点,许多设计模式在 JavaScript 之中的实现跟在一些传统面向对象语言中的实现相差很大。在 JavaScript 中,很多设计模式都是通过闭包和高阶函数实现的。这并不奇怪,相对于模式的实现过程,我们更关注的是模式可以帮助我们完成什么。