第5章:作用域闭包
我们将注意力转移到这门语言中一个非常重要但又难以掌握, 近乎神话 的概念上: 闭包 。 如果你了解了之前关于词法作用域的讨论,那么闭包的概念几乎是不言自明的。 魔术师的 幕布后藏着一个人,我们将要揭开他的伪装。
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意 识地创建闭包。闭包的创建和使用在你的代码中随处可见。你 缺少 的是根据你自己的意愿 来识别、拥抱和影响闭包的思维环境。
一、实质问题
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行。
function foo() {
var a = 2;
function bar () {
console.log(a); //2
}
bar();
}
foo();
这段代码看起来和嵌套作用域中的示例代码很相似。 基于词法作用域的查找规则, 函数 bar() 可以 访问 外部作用域中的变量 a (这个例子中的是一个 RHS 引用查询)。
这是闭包吗?
-
技术上来讲, 也许是 。 但根据前面的定义, 确切地说并不是 。 我认为最准确地用来解释 bar() 对 a 的引用的方法是词法作用域的查找规则,而这些规则 只是 闭包的 一部分 。(但却 是非常重要的一部分!)
-
从纯学术的角度说,在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的 闭包 (事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 被封闭在
了 foo() 的作用域中。为什么呢?原因简单明了,因为 bar() 嵌套在 foo() 内部。
下面我们来看一段代码,清晰地展示了闭包:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。
- 函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身 当作 一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。
- 在 foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 际上只是通过不同的标识符引用调用了内部的函数 bar() 。
- bar()显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域 以外 的地方 执行。
二、闭包带来的问题
由于发生闭包所在的内部作用域中的var = 2;变量没有被销毁,一直存在着,所以使用了很多闭包之后就会带来内存溢出的问题
在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃 圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很 自然地会考虑对其进行 回收 。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域 没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。
拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一 直存活,以供 bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar ),不出意料它可以 访问 定义时的词法作用域,因此它也可以如预期般访问变量 a 。
三、现在我懂了
-
案例1:
```js function wait(message) { setTimeout( function timer() { console.log( message ); }, 1000 ); } wait( "Hello, closure!" ); 将一个内部函数(名为 timer )传递给 setTimeout(..) 。 timer 具有涵盖 wait(..) 作用域 的闭包,因此还保有对变量 message 的引用。 wait(..) 执行1000毫秒后, 它的内部作用域并不会消失, timer 函数依然保有 wait(..)作用域的闭包。 -
案例2:
function setupBot(name, selector) { $( selector ).click( function activator() { console.log( "Activating: " + name ); } ); } setupBot( "Closure Bot 1", "#bot_1" ); setupBot( "Closure Bot 2", "#bot_2" ); 本质上 无论何时何地 ,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了 回调函数 ,实际上就是在使用闭包!
四、循环和闭包
要说明闭包, for 循环是最常见的例子。
for (var i=1; i<=5; i++) {
setTimeout(z function timer() {
console.log( i );
}, i*1000 );
}
由于很多开发者对闭包的概念认识得并不是很清楚,因此当循环内部包含函 数定义时,代码格式检查器经常发出警告。我们在这里介绍如何才能正确地 使用闭包并发挥它的威力,但是代码格式检查器并没有那么灵敏,它会假设 你并不真正了解自己在做什么,所以无论如何都会发出警告。
正常情况下,我们对这段代码行为的 预期 是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。
首先解释 6 是从哪里来的。这个循环的终止条件是 6。因此,输出显示的是循环结束时 i 的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上, 当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0) ,所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷是我们试图 假设 循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是 根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都 被封闭在一个共享的全局作用域中 ,因此实际上只有一个 i 。
这样说的话, 当然 所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的 机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环, 那它同这段代码是完全等价的。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
五、小结
- 闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人 才能够到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的 词法环境中书写代码的。
- 当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包。
- 如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循 环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现 模块 等模式。
- 模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭 包。
- 现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用的事!