让我们一起学习JavaScript闭包吧

495 查看

 

让我们一起学习JavaScript闭包吧

闭包是JavaScript中的一个基本概念,每一个认真的程序员都应该对它了如指掌。

互联网上充斥着大量关于“什么是闭包”的解释,却很少有人深入探究它“为什么”的一面。

我发现理解闭包的内在原理会使开发者们在使用开发工具时有更大的把握。所以,本文将致力于讲解闭包是如何工作的以及其工作原理的具体细节。

希望在你能从中获得更好的知识储备,以便在日常工作中更好地利用闭包。让我们开始吧!

什么是闭包?

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

 

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

注:自由变量是既不是在本地声明又不作为参数传递的一类变量。(译者注:如果一个作用域中使用的变量并不是在该作用域中声明的,那么这个变量对于该作用域来说就是自由变量)

 

让我们来看一些例子:

Example 1:

在 GitHub 上查看 rawnumberGenerator.js 

在以上例子中,numberGenerator 函数创建了一个局部的自由变量 num (一个数字) 和 checkNumber 函数 (一个在控制台打印 num 的函数)。checkNumber 函数没有自己的局部变量,但是,由于使用了闭包,它可以通过 numberGenerator 这个外部函数来访问(外部声明的)变量。因此即使在 numberGenerator 函数被返回以后,checkNumber 函数也可以使用 numberGenerator 中声明的变量 num 从而成功地在控制台记录日志。

Example 2:

在 GitHub 上查看 rawsayHello.js

在这个例子中我们演示了一个闭包包含了外围函数中声明的全部局部变量。

请注意,变量 hello 是在匿名函数之后定义的,但是该匿名函数仍然可以访问到 hello 这个变量。这是因为变量hello在创建这个函数的“作用域”时就已经被定义了,这使得它在匿名函数最终执行的时候是可用的。(不必担心,我会在本文的后面解释“作用域”是什么,现在暂时跳过它!)

深入理解闭包

这些例子从更深层次阐述了什么是闭包。总体来说情况是这样的:即使声明这些变量的外围函数已经返回以后,我们仍然可以访问在外围函数中声明的变量。显然,在这背后有一些事情发生了,使得这些变量在外围函数返回值以后仍然可以被访问到。

为了理解这是如何发生的,我们需要接触到几个相关的概念——从3000英尺的高空(抽象的概念)逐步地返回到闭包的“陆地”上来。让我们从函数运行中最重要的内容——“执行上下文”开始吧!

Execution Context   执行上下文

执行上下文是一个抽象的概念,ECMAScript 规范使用它来追踪代码的执行。它可能是你的代码第一次执行或执行的流程进入函数主体时所在的全局上下文。

执行上下文

在任意一个时间点,只能有唯一一个执行上下文在运行之中。这就是为什么 JavaScript 是“单线程”的原因,意思就是一次只能处理一个请求。一般来说,浏览器会用“栈”来保存这个执行上下文。栈是一种“后进先出” (Last In First Out) 的数据结构,即最后插入该栈的元素会最先从栈中被弹出(这是因为我们只能从栈的顶部插入或删除元素)。当前的执行上下文,或者说正在运行中的执行上下文永远在栈顶。当运行中的上下文被完全执行以后,它会由栈顶弹出,使得下一个栈顶的项接替它成为正在运行的执行上下文。

除此之外,一个执行上下文正在运行并不代表另一个执行上下文需要等待它完成运行之后才可以开始运行。有时会出现这样的情况,一个正在运行中的上下文暂停或中止,另外一个上下文开始执行。暂停的上下文可能在稍后某一时间点从它中止的位置继续执行。一个新的执行上下文被创建并推入栈顶,成为当前的执行上下文,这就是执行上下文替代的机制。

以下是这个概念在浏览器中的行为实例:

在 GitHub 上查看 rawexecutionContext.js

当 boop 返回时,它会从栈中弹出,bar 函数会恢复运行:

当我们有很多执行上下文一个接一个地运行时——通常情况下会在中间暂停然后再恢复运行——为了能很好地管理这些上下文的顺序和执行情况,我们需要用一些方法来对其状态进行追踪。而实际上也是如此,根据ECMAScript的规范,每个执行上下文都有用于跟踪代码执行进程的各种状态的组件。包括:

  • 代码执行状态:任何需要开始运行,暂停和恢复执行上下文相关代码执行的状态
  • 函数:上下文中正在执行的函数对象(正在执行的上下文是脚本或模块的情况下可能是null)
  • Realm一系列内部对象,一个ECMAScript全局环境,所有在全局环境的作用域内加载的ECMAScript代码,和其他相关的状态及资源。
  • 词法环境:用于解决此执行上下文内代码所做的标识符引用。
  • 变量环境:一种词法环境,该词法环境的环境记录保留了变量声明时在执行上下文中创建的绑定关系。

如果以上这些让你读起来很困惑,不必担心。在所有变量之中,词法环境变量是我们最感兴趣的一个,因为它明确声明它解决了这个执行上下文内代码中的“标识符引用”。你可以把“标识符”想成是变量。由于我们最初的目的就是弄清楚它是如何做到在一个函数(或“上下文”)返回以后还能神奇地访问变量,因此词法环境看起来就是我们需要深入挖掘的东西!

注意:从技术上来说,变量环境和词法环境都是用来实现闭包的,但为了简单起见,我们将这二者归纳为“环境”。想了解关于词法环境和变量环境的区别的更详尽的解释,可以参看 Alex Rauschmayer 博士这篇非常棒的文章

词法环境

定义:词法环境是一个基于 ECMAScript 代码的词法嵌套结构来定义特定变量和函数标识符的关联的规范类型。词法环境由一个环境记录及一个可能为空的对外部词法环境的引用构成。通常,一个词法环境会与ECMAScript代码的一些特定语法结构相关联,例如:FunctionDeclaration(函数声明), BlockStatement(块语句), TryStatement(Try语句)的Catch clause(Catch子句)。每当此类代码执行时,都会创建一个新的词法环境。— ECMAScript-262/6.0

让我们来把这个概念分解一下。

  • “用于定义标识符的关联”:词法环境目的就是在代码中管理数据(即标识符)。换句话说,它给标识符赋予了含义。比如当我们写出这样一行代码 “log(x /10)”如果我们没有给变量x赋予一些含义(声明变量 x),那么这个变量(或者说标识符)x 就是毫无意义的。词法环境就通过它的环境记录(参见下文)提供了这个含义(或“关联”)。
  • “词法环境包含一个环境记录”:环境记录保留了所有存在于该词法环境中的标识符及其绑定的记录。每一个词法环境都有它自己的环境记录。
  • “词法嵌套结构”:这是最有趣的部分,它大致说明了一个内部环境引用了包围它的外部环境,同时,这个外部环境还可以有它自己的外部环境。结果就是,一个环境可以作为外部环境服务于多个内部环境。全局环境是唯一一个没有外部环境的词法环境。这里会有一点难理解,让我们来用一个比喻:把词法环境想成是洋葱的层,全局环境是洋葱的最外层,随后的每一层都依次被嵌套在内部。

Source: http://4.bp.blogspot.com/

抽象地来说,(嵌套的)环境就像下面的伪代码中表现的这样: