JavaScript 之闭包™

1. 闭包简介

1.1. 什么是闭包

闭包是一种特殊的对象。它由两部分构成:函数,以及创建该函数的环境。环境由闭包创建时在作用域中的任何局部变量组成。
由于在 Javascript 语言中,只有函数内部的子函数才能读取函数中局部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。
闭包的形成与变量的作用域以及变量的生存周期密切相关。

1.2. 变量的作用域

变量的作用域无非就是两种:全局变量和局部变量。
当在函数中声明一个变量的时候,如果该变量前面没有带上关键字 var,这个变量就会成为全局变量,这当然是一种容易造成命名冲突的做法。
另外一种情况是用 var 关键字在函数中声明变量,这时候的变量即是局部变量,只有在该函数内部才能访问到这个变量,在函数外面是访问不到的。

1
2
3
4
5
6
var func = function () {
var a = 1;
alert(a); // 输出: 1
};
func();
alert(a); // 输出:Uncaught ReferenceError: a is not defined

在 JavaScript 中,函数可以用来创造函数作用域。此时的函数像一层半透明的玻璃,在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外而非从外到内的。

1
2
3
4
5
6
7
8
9
10
11
12
var a = 1;
var func1 = function () {
var b = 2;
var func2 = function () {
var c = 3;
alert(b); // 输出:2
alert(a); // 输出:1
}
func2();
alert(c); // 输出:Uncaught ReferenceError: c is not defined
};
func1();

1.3. 变量的生存周期

对于全局变量来说,全局变量的生存周期当然是永久的,除非我们主动销毁这个全局变量。
而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁:

1
2
3
4
5
var func = function () {
var a = 1; // 退出函数后局部变量 a 将被销毁
alert(a);
};
func();

而闭包可以延续局部变量的生存周期:

1
2
3
4
5
6
7
8
9
10
11
12
var func = function () {
var a = 1;
return function () {
a++;
alert(a);
}
};
var f = func();
f(); // 输出:2
f(); // 输出:3
f(); // 输出:4
f(); // 输出:5

当退出函数后,局部变量 a 并没有消失,而是似乎一直在某个地方存活着。这是因为当执行 var f = func(); 时,f 返回了一个匿名函数的引用,它可以访问到 func()被调用时产生的环境,而局部变量 a 一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命看起来被延续了。

2. 闭包的主要作用

2.1. 封装变量

任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数外部访问这些变量。私有变量包括函数的参数、局部变量和函数内定义的其他函数。
把有权访问私有变量的公有方法称为特权方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Student() {
// 私有变量
var name = "学生";
function sayHello() {
console.log("hello");
}

// 特权方法
this.getName = function () {
return name;
}
}
var student = new Student();
console.log(student.name); // undefined
console.log(student.getName()); // 学生

2.2. 减少全局变量

使用闭包模块化代码,可以减少全局变量的污染。闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”。
以加入缓存机制的求和函数为例。
没有使用闭包时需要定义全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
var cache = {}; // 缓存
var sum = function () {
var index = Array.prototype.join.call(arguments, ",");
if (cache[index]) {
return cache[index];
}
var sum = 0;
for (var i = 0; i < arguments.length; i++) {
sum += arguments[i];
}
return cache[index] = sum;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var sum = (function () {
var cache = {};
var calculate = function () {
var sum = 0;
for (var i = 0; i < arguments.length; i++) {
sum += arguments[i];
}
return sum;
}
return function () {
var index = Array.prototype.join.call(arguments, ",");
if (cache[index]) {
return cache[index];
}
return cache[index] = calculate.apply(null, arguments);
}
})();

2.3. 延续局部变量的寿命

利用闭包我们可以完成许多奇妙的工作,下面介绍一个闭包的经典应用。假设页面上有 5 个 div 节点,我们通过循环来给每个 div 绑定 onclick 事件,按照索引顺序,点击第 1 个 div 时弹出0,点击第 2 个 div 时弹出 1,以此类推。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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,所以在 div 的 onclick 事件函数中顺着作用域链从内到外查找变量 i 时,查找到的值总是 5。
解决方法是在闭包的帮助下,把每次循环的 i 值都封闭起来。当在事件函数中顺着作用域链中从内到外查找变量 i 时,会先找到被封闭在闭包环境中的 i,如果有 5 个 div,这里的 i 就分别是 0,1,2,3,4:

1
2
3
4
5
6
7
for (var i = 0, len = nodes.length; i < len; i++) {
(function (i) {
nodes[i].onclick = function () {
console.log(i);
}
})(i)
};

3. 销毁闭包

通常,函数的作用域及其所有变量都会在函数执行结束后被销毁。但是,在创建了一个闭包以后,这个函数的作用域就会一直保存到闭包不存在为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function makeAdder(x) {
return function(y) {
return x + y;
};
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

// 释放对闭包的引用
add5 = null;
add10 = null;

add5 和 add10 都是闭包。它们共享相同的函数定义,但是保存了不同的环境。在 add5 的环境中,x 为 5。而在 add10 中,x 则为 10。最后通过 null 释放了 add5 和 add10 对闭包的引用。

4. 缺陷

  • 闭包的缺点就是常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。
  • 如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。