几乎所有语言的最基础模型之一就是在变量中存储值,并且在稍后取出或修改这些值的能力。事实上,在变量中存储值和取出值的能力,给程序赋予了 状态。
如果没有这样的概念,一个程序虽然可以执行一些任务,但是它们将会受到极大的限制而且不会非常有趣。
但是在我们的程序中纳入变量,引出了我们现在将要解决的最有趣的问题:这些变量 存活 在哪里?换句话说,它们被存储在哪儿?而且,最重要的是,我们的程序如何在需要它们的时候找到它们?回答这些问题需要一组明确定义的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些变量。我们称这组规则为:作用域。
但是,这些 作用域 规则是在哪里、如何被设置的?

注脚

展开查看详情

1. 目 录 致谢 阅前必读 第一章:什么是作用域? 编译器理论 理解作用域 嵌套的作用域 错误 复习 第二章:词法作用域 词法分析时 欺骗词法作用域 复习 第三章:函数与块儿作用域 函数中的作用域 隐藏于普通作用域 函数作为作用域 块儿作为作用域 复习 第四章:提升 先有鸡还是先有蛋? 编译器再次袭来 函数优先 复习 第五章:作用域闭包 启示 事实真相 现在我能看到了 循环 + 闭包 模块 复习 附录A:动态作用域 附录B:填补块儿作用域 附录C:词法 this 本文档使用 书栈(BookStack.CN) 构建 - 1 -

2. 附录D:鸣谢 本文档使用 书栈(BookStack.CN) 构建 - 2 -

3.致谢 致谢 当前文档 《你不懂JS:作用域与闭包(You Dont Know JS)》 由 进击的皇虫 使用 书栈 (BookStack.CN) 进行构建,生成于 2018-02-07。 书栈(BookStack.CN) 仅提供文档编写、整理、归类等功能,以及对文档内容的生成和导出工 具。 文档内容由网友们编写和整理,书栈(BookStack.CN) 难以确认文档内容知识点是否错漏。如 果您在阅读文档获取知识的时候,发现文档内容有不恰当的地方,请向我们反馈,让我们共同携手, 将知识准确、高效且有效地传递给每一个人。 同时,如果您在日常生活、工作和学习中遇到有价值有营养的知识文档,欢迎分享到 书栈 (BookStack.CN) ,为知识的传承献上您的一份力量! 如果当前文档生成时间太久,请到 书栈(BookStack.CN) 获取最新的文档,以跟上知识更新换 代的步伐。 文档地址:http://www.bookstack.cn/books/You-Dont-Know-JS-scope-closures 书栈官网:http://www.bookstack.cn 书栈开源:https://github.com/TruthHun 分享,让知识传承更久远! 感谢知识的创造者,感谢知识的分享者,也感谢每一位阅读到此处的 读者,因为我们都将成为知识的传承者。 本文档使用 书栈(BookStack.CN) 构建 - 3 -

4.阅前必读 阅前必读 你不懂JS:作用域与闭包 从 O’Reilly 购买数字/印刷版 第一章:什么是作用域? 第二章:词法作用域 第三章:函数与块儿作用域 第四章:提升 第五章:作用域闭包 附录A:动态作用域 附录B:填补块儿作用域 附录C:词法 this 附录D:鸣谢 本文档使用 书栈(BookStack.CN) 构建 - 4 -

5.阅前必读 本文档使用 书栈(BookStack.CN) 构建 - 5 -

6.第一章:什么是作用域? 第一章:什么是作用域? 第一章:什么是作用域? 第一章:什么是作用域? 几乎所有语言的最基础模型之一就是在变量中存储值,并且在稍后取出或修改这些值的能力。事实 上,在变量中存储值和取出值的能力,给程序赋予了 状态。 如果没有这样的概念,一个程序虽然可以执行一些任务,但是它们将会受到极大的限制而且不会非常 有趣。 但是在我们的程序中纳入变量,引出了我们现在将要解决的最有趣的问题:这些变量 存活 在哪里? 换句话说,它们被存储在哪儿?而且,最重要的是,我们的程序如何在需要它们的时候找到它们? 回答这些问题需要一组明确定义的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些 变量。我们称这组规则为:作用域。 但是,这些 作用域 规则是在哪里、如何被设置的? 编译器理论 理解作用域 嵌套的作用域 错误 复习 本文档使用 书栈(BookStack.CN) 构建 - 6 -

7.编译器理论 编译器理论 根据你与各种编程语言打交道的水平不同,这也许是不证自明的,或者这也许令人吃惊,尽管 JavaScript 一般被划分到“动态”或者“解释型”语言的范畴,但是其实它是一个编译型语言。它 不 是 像许多传统意义上的编译型语言那样预先被编译好,编译的结果也不能在各种不同的分布式系统间 移植。 但是无论如何,JavaScript 引擎在实施许多与传统的语言编译器相同的步骤,虽然是以一种我们不 易察觉的更精巧的方式。 在传统的编译型语言处理中,一块儿源代码,你的程序,在它被执行 之前 通常将会经历三个步骤, 大致被称为“编译”: 1. 分词/词法分析: 将一连串字符打断成(对于语言来说)有意义的片段,称为 token(记 号)。举例来说,考虑这段程序: var a = 2; 。这段程序很可能会被打断成如下 token: var , a , = , 2 ,和 ; 。空格也许会被保留为一个 token,这要看它 是否是有意义的。 注意: 分词和词法分析之间的区别是微妙和学术上的,其中心在于这些 token 是否以 无状态 或 有状态 的方式被识别。简而言之,如果分词器去调用有状态的解析规则来弄清 a 是否应当 被考虑为一个不同的 token,还是只是其他 token 的一部分,那么这就是 词法分析。 2. 解析: 将一个 token 的流(数组)转换为一个嵌套元素的树,它综合地表示了程序的语法结 构。这棵树称为“抽象语法树”(AST —— Abstract Syntax Tree)。 var a = 2; 的树也许开始于称为 VariableDeclaration (变量声明)顶层节点,带有一个称 为 Identifier (标识符)的子节点(它的值为 a ),和另一个称为 AssignmentExpression (赋值表达式)的子节点,而这个子节点本身带有一个称为 NumericLiteral (数字字面量)的子节点(它的值为 2 )。 3. 代码生成: 这个处理将抽象语法树转换为可执行的代码。这一部分将根据语言,它的目标平台等 因素有很大的不同。 所以,与其深陷细节,我们不如笼统地说,有一种方法将我们上面描述的 var a = 2; 的抽象 语法树转换为机器指令,来实际上 创建 一个称为 a 的变量(包括分配内存等等),然后在 a 中存入一个值。 注意: 引擎如何管理系统资源的细节远比我们要挖掘的东西深刻,所以我们将理所当然地认为引 擎有能力按其需要创建和存储变量。 和大多数其他语言的编译器一样,JavaScript 引擎要比这区区三步复杂太多了。例如,在解析和代 码生成的处理中,一定会存在优化执行效率的步骤,包括压缩冗余元素,等等。 所以,我在此描绘的只是大框架。但是我想你很快就会明白为什么我们涵盖的这些细节是重要的,虽 本文档使用 书栈(BookStack.CN) 构建 - 7 -

8.编译器理论 所以,我在此描绘的只是大框架。但是我想你很快就会明白为什么我们涵盖的这些细节是重要的,虽 然是在很高的层次上。 其一,JavaScript 引擎没有(像其他语言的编译器那样)大把的时间去优化,因为 JavaScript 的编译和其他语言不同,不是提前发生在一个构建的步骤中。 对 JavaScript 来说,在许多情况下,编译发生在代码被执行前的仅仅几微秒之内(或更少!)。 为了确保最快的性能,JS 引擎将使用所有的招数(比如 JIT,它可以懒编译甚至是热编译,等 等),而这远超出了我们关于“作用域”的讨论。 为了简单起见,我们可以说,任何 JavaScript 代码段在它执行之前(通常是 刚好 在它执行之 前!)都必须被编译。所以,JS 编译器将把程序 var a = 2; 拿过来,并首先编译它,然后准备 运行它,通常是立即的。 本文档使用 书栈(BookStack.CN) 构建 - 8 -

9.理解作用域 理解作用域 理解作用域 演员 反复 编译器术语 引擎/作用域对话 小测验 理解作用域 我们将采用的学习作用域的方法,是将这个处理过程想象为一场对话。但是,谁 在进行这场对话呢? 演员 让我们见一见处理程序 var a = 2; 时进行互动的演员吧,这样我们就能理解稍后将要听到的它们 的对话: 1. 引擎:负责从始至终的编译和执行我们的 JavaScript 程序。 2. 编译器:引擎 的朋友之一;处理所有的解析和代码生成的重活儿(见前一节)。 3. 作用域:引擎 的另一个朋友;收集并维护一张所有被声明的标识符(变量)的列表,并对当前执 行中的代码如何访问这些变量强制实施一组严格的规则。 为了 全面理解 JavaScript 是如何工作的,你需要开始像 引擎(和它的朋友们)那样 思考,问 它们问的问题,并像它们一样回答。 反复 当你看到程序 var a = 2; 时,你很可能认为它是一个语句。但这不是我们的新朋友 引擎 所看到 的。事实上,引擎 看到两个不同的语句,一个是 编译器 将在编译期间处理的,一个是 引擎 将在 执行期间处理的。 那么,让我们来分析 引擎 和它的朋友们将如何处理程序 var a = 2; 。 编译器 将对这个程序做的第一件事情,是进行词法分析来将它分解为一系列 token,然后这些 token 被解析为一棵树。但是当 编译器 到了代码生成阶段时,它会以一种与我们可能想象的不同 的方式来对待这段程序。 一个合理的假设是,编译器 将产生的代码可以用这种假想代码概括:“为一个变量分配内存,将它标 记为 a ,然后将值 2 贴在这个变量里”。不幸的是,这不是十分准确。 本文档使用 书栈(BookStack.CN) 构建 - 9 -

10.理解作用域 编译器 将会这样处理: 1. 遇到 var a ,编译器 让 作用域 去查看对于这个特定的作用域集合,变量 a 是否已经存 在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用 域集合声明一个称为 a 的新变量。 2. 然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值 a = 2 。引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为 a 的变量可以访问。如果有,引擎 就 使用这个变量。如果没有,引擎 就查看 其他地方(参见下面的嵌套 作用域 一节)。 如果 引擎 最终找到一个变量,它就将值 2 赋予它。如果没有,引擎 将会举起它的手并喊出一 个错误! 总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量(如果先前没有 在当前作用域中声明过),第二,当执行时,引擎 在 作用域 中查询这个变量并给它赋值,如果找 到的话。 编译器术语 为了继续更深入地理解,我们需要一点儿更多的编译器术语。 当 引擎 执行 编译器 在第二步为它产生的代码时,它必须查询变量 a 来看它是否已经被声明 过了,而且这个查询是咨询 作用域 的。但是 引擎 所实施的查询的类型会影响查询的结果。 在我们这个例子中,引擎 将会对变量 a 实施一个“LHS”查询。另一种类型的查询称为“RHS”。 我打赌你能猜出“L”和“R”是什么意思。这两个术语表示“Left-hand Side(左手边)”和“Right- hand Side(右手边)” 什么的……边?赋值操作的。 换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的 右手边时,会进行 RHS 查询。 实际上,我们可以表述得更准确一点儿。对于我们的目的来说,一个 RHS 是难以察觉的,因为它简 单地查询某个变量的值,而 LHS 查询是试着找到变量容器本身,以便它可以赋值。从这种意义上 说,RHS 的含义实质上不是 真正的 “一个赋值的右手边”,更准确地说,它只是意味着“不是左手 边”。 在这一番油腔滑调之后,你也可以认为“RHS”意味着“取得他/她的源(值)”,暗示着 RHS 的意思 是“去取……的值”。 让我们挖掘得更深一些。 当我说: 本文档使用 书栈(BookStack.CN) 构建 - 10 -

11.理解作用域 1. console.log( a ); 这个指向 a 的引用是一个 RHS 引用,因为这里没有东西被赋值给 a 。而是我们在查询 a 并取得它的值,这样这个值可以被传递进 console.log(..) 。 作为对比: 1. a = 2; 这里指向 a 的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到 这个变量,将它作为 = 2 赋值操作的目标。 注意: LHS 和 RHS 意味着“赋值的左/右手边”未必像字面上那样意味着“ = 赋值操作符的左/ 右边”。赋值有几种其他的发生形式,所以最好在概念上将它考虑为:“赋值的目标(LHS)”和“赋值 的源(RHS)”。 考虑这段程序,它既有 LHS 引用又有 RHS 引用: 1. function foo(a) { 2. console.log( a ); // 2 3. } 4. 5. foo( 2 ); 调用 foo(..) 的最后一行作为一个函数调用要求一个指向 foo 的 RHS 引用,意味着,“去 查询 foo 的值,并把它交给我”。另外, (..) 意味着 foo 的值应当被执行,所以它最好 实际上是一个函数! 这里有一个微妙但重要的赋值。你发现了吗? 你可能错过了这个代码段隐含的 a = 2 。它发生在当值 2 作为参数值传递给 foo(..) 函 数时,值 2 被赋值 给了参数 a 。为了(隐含地)给参数 a 赋值,进行了一个 LHS 查 询。 这里还有一个 a 的值的 RHS 引用,它的结果值被传入 console.log(..) 。 console.log(..) 需要一个引用来执行。它为 console 对象进行一个 RHS 查询,然后发生一个属性解析来看它是 否拥有一个称为 log 的方法。 最后,我们可以将这一过程概念化为,在将值 2 (通过变量 a 的 RHS 查询得到的)传入 log(..) 时发生了一次 LHS/RHS 的交换。在 log(..) 的原生实现内部,我们可以假定它拥有 参数,其中的第一个(也许被称为 arg1 )在 2 被赋值给它之前,进行了一次 LHS 引用查 询。 注意: 你可能会试图将函数声明 function foo(a) {... 概念化为一个普通的变量声明和赋值,比 本文档使用 书栈(BookStack.CN) 构建 - 11 -

12.理解作用域 如 var foo 和 foo = function(a){... 。这样做会诱使你认为函数声明涉及了一次 LHS 查询。 然而,一个微妙但重要的不同是,在这种情况下 编译器 在代码生成期间同时处理声明和值的定义, 如此当 引擎 执行代码时,没有必要将一个函数值“赋予” foo 。因此,将函数声明考虑为一个我 们在这里讨论的 LHS 查询赋值是不太合适的。 引擎/作用域对话 1. function foo(a) { 2. console.log( a ); // 2 3. } 4. 5. foo( 2 ); 让我们将上面的(处理这个代码段的)交互想象为一场对话。这场对话将会有点儿像这样进行: 引擎:嘿 作用域,我有一个 foo 的 RHS 引用。听说过它吗? 作用域;啊,是的,听说过。编译器 刚在一秒钟之前声明了它。它是一个函数。给你。 引擎:太棒了,谢谢!好的,我要执行 foo 了。 引擎:嘿,作用域,我得到了一个 a 的 LHS 引用,听说过它吗? 作用域:啊,是的,听说过。编译器 刚才将它声明为 foo 的一个正式参数了。给你。 引擎:一如既往的给力,作用域。再次感谢你。现在,该把 2 赋值给 a 了。 引擎:嘿,作用域,很抱歉又一次打扰你。我需要 RHS 查询 console 。听说过它吗? 作用域:没关系,引擎,这是我一天到晚的工作。是的,我得到 console 了。它是一个内建对象。给你。 引擎:完美。查找 log(..) 。好的,很好,它是一个函数。 引擎:嘿,作用域。你能帮我查一下 a 的 RHS 引用吗?我想我记得它,但只是想再次确认一下。 作用域:你是对的,引擎。同一个家伙,没变。给你。 引擎:酷。传递 a 的值,也就是 2 ,给 log(..) 。 … 小测验 检查你到目前为止的理解。确保你扮演 引擎,并与 作用域 “对话”: 1. function foo(a) { 2. var b = a; 3. return a + b; 4. } 本文档使用 书栈(BookStack.CN) 构建 - 12 -

13.理解作用域 5. 6. var c = foo( 2 ); 1. 找到所有的 LHS 查询(有3处!)。 2. 找到所有的 RHS 查询(有4处!)。 注意: 小测验答案参见本章的复习部分! 本文档使用 书栈(BookStack.CN) 构建 - 13 -

14.嵌套的作用域 嵌套的作用域 建筑的隐喻 我们说过 作用域 是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的 作用域 需要 考虑。 就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域被嵌套在其他的作用域中。 所以,如果在直接作用域中找不到一个变量的话,引擎 就会咨询下一个外层作用域,如此继续直到找 到这个变量或者到达最外层作用域(也就是全局作用域)。 考虑这段代码: 1. function foo(a) { 2. console.log( a + b ); 3. } 4. 5. var b = 2; 6. 7. foo( 2 ); // 4 b 的 RHS 引用不能在函数 foo 的内部被解析,但是可以在它的外围 作用域(这个例子中是 全局作用域)中解析。 所以,重返 引擎 和 作用域 的对话,我们会听到: 引擎:“嘿, foo 的 作用域,听说过 b 吗?我得到一个它的 RHS 引用。” 作用域:“没有,从没听说过。问问别人吧。” 引擎:“嘿, foo 外面的 作用域,哦,你是全局 作用域,好吧,酷。听说过 b 吗?我得到一个它的 RHS 引用。” 作用域:“是的,当然有。给你。” 遍历嵌套 作用域 的简单规则:引擎 从当前执行的 作用域 开始,在那里查找变量,如果没有找 到,就向上走一级继续查找,如此类推。如果到了最外层的全局作用域,那么查找就会停止,无论它 是否找到了变量。 建筑的隐喻 为了将嵌套 作用域 解析的过程可视化,我想让你考虑一下这个高层建筑。 本文档使用 书栈(BookStack.CN) 构建 - 14 -

15.嵌套的作用域 这个建筑物表示我们程序的嵌套 作用域 规则集合。无论你在哪里,建筑的第一层表示你当前执行的 作用域。建筑的顶层表示全局 作用域。 你通过在你当前的楼层中查找来解析 LHS 和 RHS 引用,如果你没有找到它,就坐电梯到上一层 楼,在那里寻找,然后再上一层,如此类推。一旦你到了顶层(全局 作用域),你要么找到了你想要 的东西,要么没有。但是不管怎样你都不得不停止了。 本文档使用 书栈(BookStack.CN) 构建 - 15 -

16.错误 错误 为什么我们区别 LHS 和 RHS 那么重要? 因为在变量还没有被声明(在所有被查询的 作用域 中都没找到)的情况下,这两种类型的查询的行 为不同。 考虑如下代码: 1. function foo(a) { 2. console.log( a + b ); 3. b = a; 4. } 5. 6. foo( 2 ); 当 b 的 RHS 查询第一次发生时,它是找不到的。它被说成是一个“未声明”的变量,因为它在作 用域中找不到。 如果 RHS 查询在嵌套的 作用域 的任何地方都找不到一个值,这会导致 引擎 抛出一个 ReferenceError 。必须要注意的是这个错误的类型是 ReferenceError 。 相比之下,如果 引擎 在进行一个 LHS 查询,但到达了顶层(全局 作用域)都没有找到它,而且 如果程序没有运行在“Strict模式”[^note-strictmode]下,那么这个全局 作用域 将会在 全局 作用域中 创建一个同名的新变量,并把它交还给 引擎。 “不,之前没有这样的东西,但是我可以帮忙给你创建一个。” 在 ES5 中被加入的“Strict模式”[^note-strictmode],有许多与一般/宽松/懒惰模式不同的行 为。其中之一就是不允许自动/隐含的全局变量创建。在这种情况下,将不会有全局 作用域 的变量 交回给 LHS 查询,并且类似于 RHS 的情况, 引擎 将抛出一个 ReferenceError 。 现在,如果一个 RHS 查询的变量被找到了,但是你试着去做一些这个值不可能做到的事,比如将一 个非函数的值作为函数运行,或者引用 null 或者 undefined 值的属性,那么 引擎 就会抛出 一个不同种类的错误,称为 TypeError 。 ReferenceError 是关于 作用域 解析失败的,而 TypeError 暗示着 作用域 解析成功了,但是 试图对这个结果进行了一个非法/不可能的动作。 本文档使用 书栈(BookStack.CN) 构建 - 16 -

17.错误 本文档使用 书栈(BookStack.CN) 构建 - 17 -

18.复习 复习 小测验答案 作用域是一组规则,它决定了一个变量(标识符)在哪里和如何被查找。这种查询也许是为了向这个 变量赋值,这时变量是一个 LHS(左手边)引用,或者是为取得它的值,这时变量是一个 RHS(右 手边)引用。 LHS 引用得自赋值操作。作用域 相关的赋值可以通过 = 操作符发生,也可以通过向函数参数传 递(赋予)参数值发生。 JavaScript 引擎 在执行代码之前首先会编译它,因此,它将 var a = 2; 这样的语句分割为两 个分离的步骤: 1. 首先, var a 在当前 作用域 中声明。这是在最开始,代码执行之前实施的。 2. 稍后, a = 2 查找这个变量(LHS 引用),并且如果找到就向它赋值。 LHS 和 RHS 引用查询都从当前执行中的 作用域 开始,如果有需要(也就是,它们在这里没能找到 它们要找的东西),它们会在嵌套的 作用域 中一路向上,一次一个作用域(层)地查找这个标识 符,直到它们到达全局作用域(顶层)并停止,既可能找到也可能没找到。 未被满足的 RHS 引用会导致 ReferenceError 被抛出。未被满足的 LHS 引用会导致一个自动 的,隐含地创建的同名全局变量(如果不是“Strict模式”[^note-strictmode]),或者一个 ReferenceError (如果是“Strict模式”[^note-strictmode])。 小测验答案 1. function foo(a) { 2. var b = a; 3. return a + b; 4. } 5. 6. var c = foo( 2 ); 1. 找出所有的 LHS 查询(有3处!)。 c = .. , a = 2 (隐含的参数赋值)和 b = .. 2. 找出所有的 RHS 查询(有4处!)。 foo(2.. , = a; , a + .. 和 .. + b [^note-strictmode]: MDN: Strict Mode 本文档使用 书栈(BookStack.CN) 构建 - 18 -

19.复习 本文档使用 书栈(BookStack.CN) 构建 - 19 -

20.第二章:词法作用域 第二章:词法作用域 第二章:词法作用域 第二章:词法作用域 在第一章中,我们将“作用域”定义为一组规则,它主宰着 引擎 如何通过标识符名称在当前的 作用 域,或者在包含它的任意 嵌套作用域 中来查询一个变量, 作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被 使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域。 动态作用域在附录A中讲解。我在这里提到它仅仅是为词法作用域提供一个对比,而词法作用域是 JavaScript 所采用的作用域模型。 词法分析时 欺骗词法作用域 复习 本文档使用 书栈(BookStack.CN) 构建 - 20 -

21.词法分析时 词法分析时 查询 正如我们在第一章中讨论的,标准语言编译器的第一个传统步骤称为词法分析(也就是分词)。如果 你回忆一下,词法分析处理是检查一串源代码字符,并给 token 赋予语法含义作为某种有状态解析 的输出。 正是这个概念给理解词法作用域是什么提供了基础,它也是这个名字的渊源。 要定义它有点儿兜圈子,词法作用域是在词法分析时被定义的作用域。换句话说,词法作用域是基 于,你,在写程序时,变量和作用域的块儿在何处被编写决定的,因此它在词法分析器处理你的代码 时(基本上)是固定不变的。 注意: 我们将会稍稍看到有一些方法可以骗过词法作用域,从而在词法分析器处理过后改变它,但是 这些方法都是使人皱眉头的。事实上公认的最佳实践是,将词法作用域看作是仅仅依靠词法的,因此 自然而然地完全是编写时决定的。 让我们考虑这段代码: 1. function foo(a) { 2. 3. var b = a * 2; 4. 5. function bar(c) { 6. console.log( a, b, c ); 7. } 8. 9. bar(b * 3); 10. } 11. 12. foo( 2 ); // 2 4 12 在这个代码实例中有三个固有的嵌套作用域。将这些作用域考虑为套在一起的气泡可能有助于思考。 本文档使用 书栈(BookStack.CN) 构建 - 21 -

22.词法分析时 气泡1 包围着全局作用域,它里面只有一个标识符: foo 。 气泡2 包围着作用域 foo ,它含有三个标识符: a , bar 和 b 。 气泡3 包围着作用域 bar ,它里面只包含一个标识符: c 。 作用域气泡是根据作用域的块儿被写在何处定义的,一个嵌套在另一个内部,等等。在下一章中,我 们将讨论作用域的不同单位,但是就现在来说,让我们认为每一个函数创建了一个新的作用域气泡。 bar 的气泡完全被包含在 foo 的气泡中,因为(而且只因为)这就是我们选择定义函数 bar 的位置。 注意这些嵌套的气泡是严格嵌套的。我们没有讨论气泡可以跨越边界的维恩图(Venn diagrams)。换句话说,没有那个函数的气泡可以同时(部分地)存在于另外两个外部的作用域气 泡中,就像没有函数可以部分地存在于它的两个父函数中一样。 查询 这些作用域气泡的结构和相对位置完全解释了 引擎 在查找一个标识符时,它需要查看的所有地方。 在上面的代码段中,引擎 执行语句 console.log(..) 并开始查找三个被引用的变量 a , b 和 c 。它首先从最内部的作用域气泡开始,也就是 bar(..) 函数的作用域。在这里它找不到 a ,所以它向上走一层,到外面下一个最近的作用域气泡, foo(..) 的作用域。它在这里找到了 a ,于是它就使用这个 a 。同样的事情也发生在 b 身上。但是对于 c ,它在 bar(..) 内部就找到了。 如果在 bar(..) 内部和 foo(..) 内部都有一个 c ,那么 console.log(..) 语句将会找 到并使用 bar(..) 中的那一个,绝不会到达 foo(..) 中的那一个。 本文档使用 书栈(BookStack.CN) 构建 - 22 -

23.词法分析时 一旦找到第一个匹配,作用域查询就停止了。相同的标识符名称可以在嵌套作用域的多个层中被指 定,这称为“遮蔽(shadowing)”(内部的标识符“遮蔽”了外部的标识符)。无论如何遮蔽,作用 域查询总是从当前被执行的最内侧的作用域开始,向外/向上不断查找,直到第一个匹配才停止。 注意: 全局变量也自动地是全局对象(在浏览器中是 window ,等等)的属性,所以不直接通过全 局变量的词法名称,而通过将它作为全局对象的一个属性引用来间接地引用,是可能的。 1. window.a 这种技术给出了访问全局变量的方法,没有它全局变量将因为被遮蔽而不可访问。然而,被遮蔽的非 全局变量是无法访问的。 不管函数是从 哪里 被调用的,也不论它是 如何 被调用的,它的词法作用域是由这个函数被声明的 位置 唯一 定义的。 词法作用域查询 仅仅 在处理头等标识符时实施,比如 a , b ,和 c 。如果你在一段代码 中拥有一个 foo.bar.baz 的引用,词法作用域查询将在查找 foo 标识符时实施,但一旦定位 这个变量,对象属性访问规则将会分别接管 bar 和 baz 属性的解析。 本文档使用 书栈(BookStack.CN) 构建 - 23 -

24.欺骗词法作用域 欺骗词法作用域 本文档使用 书栈(BookStack.CN) 构建 - 24 -

25.复习 复习 词法作用域意味着作用域是由编写时函数被声明的位置的决策定义的。编译器的词法分析阶段实质上 可以知道所有的标识符是在哪里和如何声明的,并如此在执行期间预测它们将如何被查询。 在 JavaScript 中有两种机制可以“欺骗”词法作用域: eval(..) 和 with 。前者可以通过对 一个拥有一个或多个声明的“代码”字符串进行求值,来(在运行时)修改现存的词法作用域。后者实 质上是通过将一个对象引用看作一个“作用域”,并将这个对象的属性看作作用域中的标识符,(同 样,也是在运行时)创建一个全新的词法作用域。 这些机制的缺点是,它压制了 引擎 在作用域查询上进行编译期优化的能力,因为 引擎 不得不悲观 地假定这样的优化是无效的。这两种特性的结果就是代码 将 会运行的更慢。不要使用它们。 本文档使用 书栈(BookStack.CN) 构建 - 25 -

26.第三章:函数与块儿作用域 第三章:函数与块儿作用域 第三章:函数与块儿作用域 第三章:函数与块儿作用域 正如我们在第二章中探索的,作用域由一系列“气泡”组成,这些“气泡”的每一个就像一个容器或篮 子,标识符(变量,函数)就在它里面被声明。这些气泡整齐地互相嵌套在一起,而且这种嵌套是在 编写时定义的。 但是到底是什么才能制造一个新气泡?只能是函数吗?JavaScript 中的其他结构可以创建作用域的 气泡吗? 函数中的作用域 隐藏于普通作用域 函数作为作用域 块儿作为作用域 复习 本文档使用 书栈(BookStack.CN) 构建 - 26 -

27.函数中的作用域 函数中的作用域 对这些问题的最常见的回答是,JavaScript 拥有基于函数的作用域。也就是,你声明的每一个函数 都为自己创建了一个气泡,而且没有其他的结构可以创建它们自己的作用域气泡。但是就像我们一会 儿将会看到的,这不完全正确。 但首先,让我们探索一下函数作用域和它的含义。 考虑这段代码: 1. function foo(a) { 2. var b = 2; 3. 4. // 一些代码 5. 6. function bar() { 7. // ... 8. } 9. 10. // 更多代码 11. 12. var c = 3; 13. } 在这个代码段中, foo(..) 的作用域气泡包含标识符 a , b , c 和 bar 。一个声明 出现在作用域 何处 是 无关紧要的,不管怎样,变量和函数属于包含它们的作用域气泡。在下一章 中我们将会探索这到底是如何工作的。 bar(..) 拥有它自己的作用域气泡。全局作用域也一样,它仅含有一个标识符: foo 。 因为 a , b , c ,和 bar 都属于 foo(..) 的作用域气泡,所以它们在 foo(..) 外部是不可访问的。也就是,接下来的代码都会得到 ReferenceError 错误,因为这些标识符在全 局作用域中都不可用: 1. bar(); // 失败 2. 3. console.log( a, b, c ); // 3个都失败 然而,所有这些标识符( a , b , c ,和 bar )在 foo(..) 内部 都是可以访问的, 而且在 bar(..) 内部也都是可用的(假定在 bar(..) 内部没有遮蔽标识符的声明)。 函数作用域支持着这样的想法:所有变量都属于函数,而且贯穿整个函数始终都可以使用和重用(而 且甚至可以在嵌套的作用域中访问)。这种设计方式可以十分有用,而且肯定可以完全利用 本文档使用 书栈(BookStack.CN) 构建 - 27 -

28.函数中的作用域 且甚至可以在嵌套的作用域中访问)。这种设计方式可以十分有用,而且肯定可以完全利用 JavaScript 的“动态”性质 —— 变量可以根据需要接受不同种类型的值。 另一方面,如果你不小心提防,跨越整个作用域存在的变量可能会导致一些意料之外的陷阱。 本文档使用 书栈(BookStack.CN) 构建 - 28 -

29.隐藏于普通作用域 隐藏于普通作用域 避免冲突 全局“名称空间” 模块管理 考虑一个函数的传统方式是,你声明一个函数,并在它内部添加代码。但是相反的想法也同样强大和 有用:拿你所编写的代码的任意一部分,在它周围包装一个函数声明,这实质上“隐藏”了这段代码。 其实际结果是在这段代码周围创建了一个作用域气泡,这意味着现在在这段代码中的任何声明都将绑 在这个新的包装函数的作用域上,而不是前一个包含它们的作用域。换句话说,你可以通过将变量和 函数围在一个函数的作用域中来“隐藏”它们。 为什么“隐藏”变量和函数是一种有用的技术? 有多种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为“最低权限原则”的软件设计原则引 起的[^note-leastprivilege],有时也被称为“最低授权”或“最少曝光”。这个原则规定,在软件 设计中,比如一个模块/对象的API,你应当只暴露所需要的最低限度的东西,而“隐藏”其他的一切。 这个原则可以扩展到用哪个作用域来包含变量和函数的选择。如果所有的变量和函数都在全局作用域 中,它们将理所当然地对任何嵌套的作用域来说都是可访问的。但这回违背“最少……”原则,因为你 (很可能)暴露了许多你本应当保持为私有的变量和函数,而这些代码的恰当用法是不鼓励访问这些 变量/函数的。 例如: 1. function doSomething(a) { 2. b = a + doSomethingElse( a * 2 ); 3. 4. console.log( b * 3 ); 5. } 6. 7. function doSomethingElse(a) { 8. return a - 1; 9. } 10. 11. var b; 12. 13. doSomething( 2 ); // 15 在这个代码段中,变量 b 和函数 doSomethingElse(..) 很可能是 doSomething(..) 如何工 作的“私有”细节。允许外围的作用域“访问” b 和 doSomethingElse(..) 不仅没必要而且可能 是“危险的”,因为它们可能会以种种意外的方式,有意或无意地被使用,而这也许违背了 doSomething(..) 假设的前提条件。 本文档使用 书栈(BookStack.CN) 构建 - 29 -