logo头像

总有人间一两风,填我十万八千梦

前端面试系列(10)——JS中的闭包

闭包这个概念其实并不是 JS 中独有的,很多开发者将其理解为 JS 的特有产物,其实是大错特错的;只不过 JS 中的闭包有着其他语言没有的特性和产生机理,所以对于闭包的理解成为了很多面试官垂青的问题,而真正理解闭包并且知道在什么时候用闭包、在什么时候避免闭包对于前端码农来说是一个不小的挑战,本篇文章就将深入剖析闭包的工作原理,以及如何使用和避免使用闭包

作用域

要理解闭包,首先必须理解 Javascript 特殊的变量作用域;在 JS 中,变量的作用域无非就是两种:全局变量和局部变量:

  • 全局变量,顾名思义,在函数内部也可以直接读取全局变量
  • 局部变量,在函数外部是无法读取函数内的局部变量的(函数内声明变量的时候,一定要使用 var / let 命令,否则相当于声明了一个全局变量

我们有时候需要得到函数内的局部变量,但是从上面的讲解可以看到,正常情况下是办不到的,所以只能变通:在函数的内部,再定义一个函数

1
2
3
4
5
6
function f1() {
var n = 999;
function f2() {
alert(n); //999
}
}

这里就引出了另外一个概念,就是 Javascript 语言特有的“链式作用域”结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量;援引燕十八老师的话,只要在一对大括号之内声明的变量,在这个大括号里面的任何地方都可以访问到该变量。
根据上面的代码,既然 f2 可以读取 f1 中的局部变量,那么只要把 f2 作为返回值,我们就可以在 f1 外部读取它的内部变量了:

1
2
3
4
5
6
7
8
9
function f1(){
var n=999;
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999

什么是闭包?

上面代码中的 f2 函数,就是闭包。 闭包是 JavaScript(以及其他大多数编程语言)的一个极其强大的属性。正如在 MDN (Mozilla Developer Network) 中定义的那样:

闭包是指能够访问自由变量的函数。换句话说,在闭包中定义的函数可以“记忆”它被创建的环境。

自由变量是既不是在本地声明又不作为参数传递的一类变量。(如果一个作用域中使用的变量并不是在该作用域中声明的,那么这个变量对于该作用域来说就是自由变量),上面例子中的 n 在 f2 中 alert,但是 f2 中并没有声明 n,所以 n 对于 f2 这个大括号形成的作用域来说就是自由变量;更通俗来讲的话,闭包是能够读取其他函数内部变量的函数,所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁

闭包的用途

闭包一个非常重要的用途:保留外部作用域对一个变量的私有引用(仅通过唯一途径例如某一个特定函数来访问一个变量),来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
var result = [];
for (var i = 0; i < 5; i++) {
result[i] = function () {
console.log(i);
};
}
result[0](); // 5, expected 0
result[1](); // 5, expected 1
result[2](); // 5, expected 2
result[3](); // 5, expected 3
result[4](); // 5, expected 4

这里之所以会打印 5 个 “5”,是因为这五个函数的作用域全部相同(var i = 0 这一句可以提到 for 循环外面,对于 5 个函数来说,只有一个 i,就是循环结束时的那个 i);也就是说,每次变量 i 增加时,作用域都会更新–这个作用域被所有函数共享。一个解决办法就是为每个函数创建一个额外的封闭环境,使得它们各自都有自己的执行上下文 / 作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var result = [];
for (var i = 0; i < 5; i++) {
result[i] = (function inner(x) {
// additional enclosing context
return function() {
console.log(x);
}
})(i);
}
result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4

另外,因为 ES6 的缘故,所以我们可以使用 let 来代替 var,因为 let 声明的是块级作用域(在 ES5 中,是没有块级作用域的),因此每次迭代都会创建一个新的标示符绑定:

1
2
3
4
5
6
7
8
9
10
11
var result = [];
for (let i = 0; i < 5; i++) {
result[i] = function () {
console.log(i);
};
}
result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4

再来看一个例子(MDN 给出的一个闭包的例子):

1
2
3
4
5
6
7
8
9
10
11
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

我们定义了一个方法 makeAdder(x),这个方法只有一个变量 x,然后返回了一个新的方法;返回的方法呢只有一个变量 y,然后 return 的结果是 x + y;所以,makeAdder(x) 就被我们打造成了一个“方法工厂”,在上面的例子中我们利用这个“工厂”生产了两个新的方法,一个返回的结果是 5 加上变量,另外一个返回 10 加上传进来的变量;不出所料,add5 和 add10 这两个方法都是闭包,他们共享同样的方法体定义,但是存储了不同的词法环境(关于词法环境,本文将不详细探讨,感兴趣的可以自行查阅资料,暂时可以简单的理解为变量所在的环境);在 add5 的词法环境,x 是 5;而在 add10 的词法环境中,x 是 10;通过这个例子,我们可以看到闭包可以用来打造“方法工厂”,而这个特性也成为了我们避免使用闭包的理由

避免使用闭包

曾经我被闭包强大的特性所吸引,直到我看到一些关于“避免使用闭包”的博客,才知道闭包带来的麻烦会比其提供的方便更值得重视;JS 的内存释放和 Java 类似,有一个内存回收机制,没有被引用的对象都会被自动释放,而出现闭包的时候会导致变量无法被释放,下面看一个例子:

1
2
3
4
5
6
7
function closure(){
var data = {};
return function(){
return data;
}
}
var closure1 = closure();

closure 方法返回的这个方法,在 closure1 方法每次调用的时候,都可以访问 data 对象,所以由此可见,data 对象的引用没有被释放,否则的话 closure1 方法将无法访问到data对象。这里可以明显的看出来闭包是会把局部变量引用起来导致无法释放的“副作用”:

1
2
3
var closure2 = closure();
console.log(closure1 === closure2); // false
console.log(closure1() === closure2()); // false

从上面的代码可以看出来,closure 方法执行两次得到两个方法,这两个方法不是一个方法,两个方法可以访问的 data 对象也不是同一个对象。也就是说 closure 执行一次,就有一个新对象 data 产生,同时生成一个新的方法,返回出去。每次 closure 方法的执行就导致内存中多了一个 data 对象,多了一个 function(return data),很明显这会导致内存的膨胀。使用不当就会导致内存的泄露

扩展阅读

关于闭包就介绍到这里了,但是为了更容易让初学者理解,本文省略掉了很多相关概念的介绍(执行上下文、词法环境、静态作用域),感兴趣的话可以查阅相关资料,如果有机会的话,我会再查阅更多资料,详细的介绍一下和闭包有关的其他概念,下面是我推荐的一些关于闭包讲解的链接:

支付宝打赏 微信打赏

听说赞过就能年薪百万