读书笔记-你不知道的JS系列- 第5章:作用域闭包

233 阅读7分钟

第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. 案例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. 案例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)包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭 包。
  • 现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用的事!