JavaScript之闭包的理解

什么是闭包(Closure)

首先来看下列代码

1
2
3
4
5
6
7
8
9
10
function foo() {
var local = 0;
function bar() {
local++;
console.log( local );
}
return bar;
}
var func = foo();
func();

在这个例子中,函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作一个值类型进行传递。
foo()执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 func 并调用 func(),实际上只是通过不同的标识符引用调用了内部的函数 bar()
显然bar()会被正常执行。更进一步地说,它是在自己定义的词法作用域以外的地方执行。

在 JavaScript 中,因为有垃圾回收器的存在,因此在一个函数被执行后,如果检测到他的内容在之后不会再被使用,那么引擎会考虑对其进行回收。(对于 JavaScript 的垃圾回收,可以参考这篇文章)

然而在这个例子中,foo()被执行后,事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?是 bar() 本身在使用。

bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

换句话说,bar() 持有对该作用域的引用,而这个引用就叫作闭包。

因此,在几微秒之后变量 func 被实际调用,不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问局部变量 local

这几行代码可能过于复杂,我们可以把它精简成这样:

1
2
3
4
5
var local = 0;
function bar() {
console.log( local );
}

在这三行代码中,bar 可以访问到 local 变量,这就是一个闭包。诚然如此,但我们通常会把这三行代码放在一个函数里。为什么呢?这就涉及到闭包的作用了。

闭包的作用

闭包通常用来间接访问一个变量。也就是说,可以隐藏一个变量使它不能被直接访问。

要想达到这个效果,就可以把这个变量放在一个作用域内,然后单独创建一个对他进行控制的函数,这样我们就只能通过这个函数去访问它。

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用
域的引用,无论在何处执行这个函数都会使用闭包。

一些理解

无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,就会产生闭包。

定时器事件监听器Ajax 请求跨窗口通信Web Workers 或者任何其他的异步或者同步任务中,只要使用了回调函数,实际上也是在使用闭包。

比如下列代码:

1
2
3
4
5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

正常情况下,我们对这段代码行为的预期是依次输出数字 1~5,每秒一次输出一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

但是根据作用域的工作原理,尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。也就是说,所有函数都共享一个 i 的引用。

而这个循环的终止条件是 i > 5。条件首次成立时 i 的值是 6。因此,输出显示的是循环结束时 i 的最终值。

那么怎么解决呢?

我们可以使用 IIFE (Immediately Invoked Function Expression)即立即调用函数来对 i 创建一个单独的作用域,并使用另一个变量 j 在每次迭代中存放 i 的值,代码如下:

1
2
3
4
5
6
7
8
for (var i = 1; i <= 5; i++) {
(function() {
var j = i;
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})();
}

这样代码就能按照预期输出 1~5 了。