你不知道的JavaScript 上卷 第一部分 作用域和闭包

第1章 作用域是什么

几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能在之后对这个
值进行访问或修改。事实上,正是这种储存和访问变量的值的能力将状态带给了程序。

若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但它会受到高度限制,做
不到非常有趣。

但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些变量住在
哪里?换句话说,它们储存在哪里?最重要的是,程序需要时如何找到它们?

这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。
这套规则被称为作用域。

1.1 编译原理

尽管通常将JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。
这个事实对你来说可能显而易见,也可能你闻所未闻,取决于你接触过多少编程语言,具
有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系
统中进行移植。
尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能
比预想的要复杂。

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编
译”。

  • 分词/词法分析(Tokenizing/Lexing)
    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代
    码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成
    为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在
    这门语言中是否具有意义。
  • 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法
    结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
    var a = 2; 的抽象语法树中可能会有一个叫作VariableDeclaration 的顶级节点,接下
    来是一个叫作Identifier(它的值是a)的子节点,以及一个叫作AssignmentExpression
    的子节点。AssignmentExpression 节点有一个叫作NumericLiteral(它的值是2)的子
    节点。
  • 代码生成
    将AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息
    息相关。
    抛开具体细节,简单来说就是有某种方法可以将var a = 2; 的AST 转化为一组机器指
    令,用来创建一个叫作a 的变量(包括分配内存等),并将一个值储存在a 中。

比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在
语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化
等。
因此在这里只进行宏观、简单的介绍,接下来你就会发现我们介绍的这些看起来有点高深
的内容与所要讨论的事情有什么关联。

首先,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因
为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
对于JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时
间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如JIT,可以延
迟编译甚至实施重编译)来保证性能最佳。
简单地说,任何JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此,
JavaScript 编译器首先会对var a = 2; 这段程序进行编译,然后做好执行它的准备,并且
通常马上就会执行它。

1.2 理解作用域

我们学习作用域的方式是将这个过程模拟成几个人物之间的对话。那么,由谁进行这场对
话呢?

1.2.1 演员表

首先介绍将要参与到对程序var a = 2; 进行处理的过程中的演员们,这样才能理解接下来
将要听到的对话。

  • 引擎
    从头到尾负责整个JavaScript 程序的编译及执行过程。
  • 编译器
    引擎的好朋友之一,负责语法分析及代码生成等脏活累活(详见前一节的内容)。
  • 作用域
    引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查
    询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

为了能够完全理解JavaScript 的工作原理,你需要开始像引擎(和它的朋友们)一样思考,
从它们的角度提出问题,并从它们的角度回答这些问题。

1.2.2 对话

当你看见var a = 2; 这段程序时,很可能认为这是一句声明。但我们的新朋友引擎却不这
么看。事实上,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一
个则由引擎在运行时处理。
下面我们将var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编
译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。
可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内
存,将其命名为a,然后将值2 保存进这个变量。”然而,这并不完全正确。
事实上编译器会进行如下处理。

  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的
    集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作
    用域的集合中声明一个新的变量,并命名为a。

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2 这个赋值
    操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a 的
    变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看1.3
    节)。

如果引擎最终找到了a 变量,就会将2 赋值给它。否则引擎就会举手示意并抛出一个异
常!

总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如
果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对
它赋值。

1.2.3 编译器有话说

编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量a 来判断它是
否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查
找结果。

在我们的例子中,引擎会为变量a 进行LHS 查询。另外一个查找的类型叫作RHS。

我打赌你一定能猜到“L”和“R”的含义,它们分别代表左侧和右侧。

什么东西的左侧和右侧?是一个赋值操作的左侧和右侧。

换句话说,当变量出现在赋值操作的左侧时进行LHS 查询,出现在右侧时进行RHS 查询。
讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而LHS 查询则是试图
找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋
值操作的右侧”,更准确地说是“非左侧”。

你可以将RHS 理解成retrieve his source value(取到它的源值),这意味着“得到某某的
值”。

考虑以下代码:

console.log( a );

其中对a 的引用是一个RHS 引用,因为这里a 并没有赋予任何值。相应地,需要查找并取
得a 的值,这样才能将值传递给console.log(..)。

相比之下,例如:

a = 2;

这里对a 的引用则是LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为=
2 这个赋值操作找到一个目标。

LHS 和RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=
赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最
好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头
(RHS)”。

考虑下面的程序,其中既有LHS 也有RHS 引用:

function foo(a) {
console.log( a ); // 2
}
foo( 2 );

最后一行foo(..) 函数的调用需要对foo 进行RHS 引用,意味着“去找到foo 的值,并把
它给我”。并且(..) 意味着foo 的值需要被执行,因此它最好真的是一个函数类型的值!
这里还有一个容易被忽略却非常重要的细节。
代码中隐式的a=2 操作可能很容易被你忽略掉。这个操作发生在2 被当作参数传递给
foo(..) 函数时,2 会被分配给参数a。为了给参数a(隐式地)分配值,需要进行一次
LHS 查询。

这里还有对a 进行的RHS 引用, 并且将得到的值传给了console.log(..)。console.
log(..) 本身也需要一个引用才能执行,因此会对console 对象进行RHS 查询,并且检查
得到的值中是否有一个叫作log 的方法。

最后,在概念上可以理解为在LHS 和RHS 之间通过对值2 进行交互来将其传递进log(..)
(通过变量a 的RHS 查询)。假设在log(..) 函数的原生实现中它可以接受参数,在将2 赋
值给其中第一个(也许叫作arg1)参数之前,这个参数需要进行LHS 引用查询。

你可能会倾向于将函数声明function foo(a) {… 概念化为普通的变量声明
和赋值,比如var foo、foo = function(a) {…。如果这样理解的话,这
个函数声明将需要进行LHS 查询。
然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值
的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值“分
配给”foo。因此,将函数声明理解成前面讨论的LHS 查询和赋值的形式并
不合适。

1.2.4 引擎和作用域的对话
function foo(a) {
console.log( a ); // 2
}
foo( 2 );

让我们把上面这段代码的处理过程想象成一段对话,这段对话可能是下面这样的。

引擎:我说作用域,我需要为foo 进行RHS 引用。你见过它吗?
作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。
引擎:哥们太够意思了!好吧,我来执行一下foo。
引擎:作用域,还有个事儿。我需要为a 进行LHS 引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声名为foo 的一个形式参数了,拿去吧。
引擎:大恩不言谢,你总是这么棒。现在我要把2 赋值给a。
引擎:哥们,不好意思又来打扰你。我要为console 进行RHS 引用,你见过它吗?
作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console 是个内置对象。
给你。
引擎:么么哒。我得看看这里面是不是有log(..)。太好了,找到了,是一个函数。
引擎:哥们,能帮我再找一下对a 的RHS 引用吗?虽然我记得它,但想再确认一次。
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:真棒。我来把a 的值,也就是2,传递进log(..)。
……

1.2.5 小测验

检验一下到目前的理解程度。把自己当作引擎,并同作用域进行一次“对话”:

function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
  1. 找到其中所有的LHS 查询。(这里有3 处!)
  2. 找到其中所有的RHS 查询。(这里有4 处!)
1.3 作用域嵌套

我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个
作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用
域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,
或抵达最外层的作用域(也就是全局作用域)为止。
考虑以下代码:

function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4

对b 进行的RHS 引用无法在函数foo 内部完成,但可以在上一级作用域(在这个例子中就
是全局作用域)中完成。
因此,回顾一下引擎和作用域之间的对话,会进一步听到:

引擎:foo 的作用域兄弟,你见过b 吗?我需要对它进行RHS 引用。
作用域:听都没听过,走开。
引擎:foo 的上级作用域兄弟,咦?有眼不识泰山,原来你是全局作用域大哥,
太好了。你见过b 吗?我需要对它进行RHS 引用。
作用域:当然了,给你吧。

遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,
就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都
会停止。

把作用域链比喻成一个建筑

为了将作用域处理的过程可视化,我希望你在脑中想象下面这个高大的建筑:
这个建筑代表程序中的嵌套作用域链。第一层楼代表当前的执行作用域,也就是你所处的
位置。建筑的顶层代表全局作用域。
LHS 和RHS 引用都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,
如果还是没有找到就继续向上,以此类推。一旦抵达顶层(全局作用域),可能找到了你
所需的变量,也可能没找到,但无论如何查找过程都将停止。

1.4 异常

为什么区分LHS 和RHS 是一件重要的事情?

因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行
为是不一样的。
考虑如下代码:

function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );

第一次对b 进行RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变
量,因为在任何相关的作用域中都无法找到它。
如果RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError
异常。值得注意的是,ReferenceError 是非常重要的异常类型。
相较之下,当引擎执行LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,
全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非
“严格模式”下。
“不,这个变量之前并不存在,但是我很热心地帮你创建了一个。”
ES5 中引入了“严格模式”。同正常模式,或者说宽松/ 懒惰模式相比,严格模式在行为上
有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在
严格模式中LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS 查询
失败时类似的ReferenceError 异常。
接下来,如果RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,
比如试图对一个非函数类型的值进行函数调用,或着引用null 或undefined 类型的值中的
属性,那么引擎会抛出另外一种类型的异常,叫作TypeError。
ReferenceError 同作用域判别失败相关,而TypeError 则代表作用域判别成功了,但是对
结果的操作是非法或不合理的。

1.5 小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对
变量进行赋值,那么就会使用LHS 查询;如果目的是获取变量的值,就会使用RHS 查询。

赋值操作符会导致LHS 查询。=操作符或调用函数时传入参数的操作都会导致关联作用域
的赋值操作。

JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2 这样的声
明会被分解成两个独立的步骤:

  • 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
  • 接下来,a = 2 会查询(LHS 查询)变量a 并对其进行赋值。

LHS 和RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所
需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层
楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。

不成功的RHS 引用会导致抛出ReferenceError 异常。不成功的LHS 引用会导致自动隐式
地创建一个全局变量(非严格模式下),该变量使用LHS 引用的目标作为标识符,或者抛
出ReferenceError 异常(严格模式下)。

小测验答案
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
  • 找出所有的LHS 查询(这里有3 处!)
    c = ..;、a = 2(隐式变量分配)、b = ..
  • 找出所有的RHS 查询(这里有4 处!)
    foo(2..、= a;、a ..、.. b
第2章 词法作用域

在第1 章中,我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用
域以及嵌套的子作用域中根据标识符名称进行变量查找。

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法
作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语
言在使用(比如Bash 脚本、Perl 中的一些模式等)。

附录A 中介绍了动态作用域,在这里提到它只是为了同JavaScript 所采用的作用域模型,
即词法作用域模型进行对比。

2.1 词法阶段

第1 章介绍过,大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。回
忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋
予单词语义。
这个概念是理解词法作用域及其名称来历的基础。
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写
代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域
不变(大部分情况下是这样的)。

后面会介绍一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依
然可以修改作用域,但是这种机制可能有点难以理解。事实上,让词法作用
域根据词法关系保持书写时的自然关系不变,是一个非常好的最佳实践。

考虑以下代码:

function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含
的气泡。

  • 包含着整个全局作用域,其中只有一个标识符:foo。
  • 包含着foo 所创建的作用域,其中有三个标识符:a、bar 和b。
  • 包含着bar 所创建的作用域,其中只有一个标识符:c。

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。下一章会讨论不
同类型的作用域,但现在只要假设每一个函数都会创建一个新的作用域气泡就好了。

bar 的气泡被完全包含在foo 所创建的气泡中,唯一的原因是那里就是我们希望定义函数
bar 的位置。

注意,这里所说的气泡是严格包含的。我们并不是在讨论文氏图1 这种可以跨越边界的气
泡。换句话说,没有任何函数的气泡可以(部分地)同时出现在两个外部作用域的气泡
中,就如同没有任何函数可以部分地同时出现在两个父级函数中一样。

查找

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息
来查找标识符的位置。
在上一个代码片段中,引擎执行console.log(..) 声明,并查找a、b 和c 三个变量的引
用。它首先从最内部的作用域,也就是bar(..) 函数的作用域气泡开始查找。引擎无法在
这里找到a,因此会去上一级到所嵌套的foo(..) 的作用域中继续查找。在这里找到了a,
因此引擎使用了这个引用。对b 来讲也是一样的。而对c 来说,引擎在bar(..) 中就找到
了它。
如果a、c 都存在于bar(..) 和foo(..) 的内部,console.log(..) 就可以直接使用bar(..)
中的变量,而无需到外面的foo(..) 中查找。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的
标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,
作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见
第一个匹配的标识符为止。
全局变量会自动成为全局对象(比如浏览器中的window 对象)的属性,因此
可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引
用来对其进行访问。
window.a
通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量
如果被遮蔽了,无论如何都无法被访问到。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处
的位置决定。
词法作用域查找只会查找一级标识符,比如a、b 和c。如果代码中引用了foo.bar.baz,
词法作用域查找只会试图查找foo 标识符,找到这个变量后,对象属性访问规则会分别接
管对bar 和baz 属性的访问。

2.2 欺骗词法

如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修
改”(也可以说欺骗)词法作用域呢?
JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是
什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能
下降。
在详细解释性能问题之前,先来看看这两种机制分别是什么原理。

2.2.1 eval

JavaScript 中的eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书
写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并
运行,就好像代码是写在那个位置的一样。
根据这个原理来理解eval(..),它是如何通过代码欺骗和假装成书写时(也就是词法期)
代码就在那,来实现修改词法作用域环境的,这个原理就变得清晰易懂了。
在执行eval(..) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插
入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。
考虑以下代码:

function foo(str, a) {
eval( str ); // 欺骗!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

eval(..) 调用中的”var b = 3;” 这段代码会被当作本来就在那里一样来处理。由于那段代
码声明了一个新的变量b,因此它对已经存在的foo(..) 的词法作用域进行了修改。事实
上,和前面提到的原理一样,这段代码实际上在foo(..) 内部创建了一个变量b,并遮蔽
了外部(全局)作用域中的同名变量。
当console.log(..) 被执行时,会在foo(..) 的内部同时找到a 和b,但是永远也无法找到
外部的b。因此会输出“1, 3”而不是正常情况下会输出的“1, 2”。

在这个例子中,为了展示的方便和简洁,我们传递进去的“代码”字符串是
固定不变的。而在实际情况中,可以非常容易地根据程序逻辑动态地将字符
拼接在一起之后再传递进去。eval(..) 通常被用来执行动态创建的代码,因
为像例子中这样动态地执行一段固定字符所组成的代码,并没有比直接将代
码写在那里更有好处。

默认情况下,如果eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函
数),就会对eval(..) 所处的词法作用域进行修改。技术上,通过一些技巧(已经超出我
们的讨论范围)可以间接调用eval(..) 来使其运行在全局作用域中,并对全局作用域进行
修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。

在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其
中的声明无法修改所在的作用域。

function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );

JavaScript 中还有其他一些功能效果和eval(..) 很相似。setTimeout(..) 和
setInterval(..) 的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的
函数代码。这些功能已经过时且并不被提倡。不要使用它们!
new Function(..) 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转
化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比
eval(..) 略微安全一些,但也要尽量避免使用。
在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损
失。

2.2.2 with

JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是
with 关键字。可以有很多方法来解释with,在这里我选择从这个角度来解释它:它如何同
被它所影响的词法作用域进行交互。
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象
本身。
比如:

var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}

但实际上这不仅仅是为了方便地访问对象属性。考虑如下代码:

function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

这个例子中创建了o1 和o2 两个对象。其中一个具有a 属性,另外一个没有。foo(..) 函
数接受一个obj 参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。
在with 块内部,我们写的代码看起来只是对变量a 进行简单的词法引用,实际上就是一个
LHS 引用(查看第1 章),并将2 赋值给它。
当我们将o1 传递进去,a=2 赋值操作找到了o1.a 并将2 赋值给它,这在后面的console.
log(o1.a) 中可以体现。而当o2 传递进去,o2 并没有a 属性,因此不会创建这个属性,
o2.a 保持undefined。
但是可以注意到一个奇怪的副作用,实际上a = 2 赋值操作创建了一个全局的变量a。这
是怎么回事?
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对
象的属性也会被处理为定义在这个作用域中的词法标识符。

尽管with 块可以将一个对象处理为词法作用域,但是这个块内部正常的var
声明并不会被限制在这个块的作用域中,而是被添加到with 所处的函数作
用域中。

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而
with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
可以这样理解,当我们传递o1 给with 时,with 所声明的作用域是o1,而这个作用域中含
有一个同o1.a 属性相符的标识符。但当我们将o2 作为作用域时,其中并没有a 标识符,
因此进行了正常的LHS 标识符查找(查看第1 章)。
o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行
时,自动创建了一个全局变量(因为是非严格模式)。
with 这种将对象及其属性放进一个作用域并同时分配标识符的行为很让人费解。但为了说
明我们所看到的现象,这是我能给出的最直白的解释了。

另外一个不推荐使用eval(..) 和with 的原因是会被严格模式所影响(限
制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用
eval(..) 也被禁止了。

2.2.3 性能

eval(..) 和with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词
法作用域。
你可能会问,那又怎样呢?如果它们能实现更复杂的功能,并且代码更具有扩展性,难道
不是非常好的功能吗?答案是否定的。
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的
词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到
标识符。

但如果引擎在代码中发现了eval(..) 或with,它只能简单地假设关于标识符位置的判断
都是无效的,因为无法在词法分析阶段明确知道eval(..) 会接收到什么代码,这些代码会
如何对作用域进行修改,也无法知道传递给with 用来创建新词法作用域的对象的内容到底
是什么。
最悲观的情况是如果出现了eval(..) 或with,所有的优化可能都是无意义的,因此最简
单的做法就是完全不做任何优化。
如果代码中大量使用eval(..) 或with,那么运行起来一定会变得非常慢。无论引擎多聪
明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代
码会运行得更慢这个事实。

2.3 小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段
基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它
们进行查找。
JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..) 和with。前者可以对一段包
含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在
运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作
用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。
这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认
为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

第3章 函数作用域和块作用域

正如我们在第2 章中讨论的那样,作用域包含了一系列的“气泡”,每一个都可以作为容
器,其中包含了标识符(变量、函数)的定义。这些气泡互相嵌套并且整齐地排列成蜂窝
型,排列的结构是在写代码时定义的。
但是,究竟是什么生成了一个新的气泡?只有函数会生成新的气泡吗? JavaScript 中的其
他结构能生成作用域气泡吗?

3.1 函数中的作用域

对于前面提出的问题,最常见的答案是JavaScript 具有基于函数的作用域,意味着每声明
一个函数都会为其自身创建一个气泡,而其他结构都不会创建作用域气泡。但事实上这并
不完全正确,下面我们来看一下。
首先需要研究一下函数作用域及其背后的一些内容。
考虑下面的代码:

function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多的代码
var c = 3;
}

在这个代码片段中,foo(..) 的作用域气泡中包含了标识符a、b、c 和bar。无论标识符
声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气
泡。我们将在下一章讨论具体的原理。
bar(..) 拥有自己的作用域气泡。全局作用域也有自己的作用域气泡,它只包含了一个标
识符:foo。
由于标识符a、b、c 和bar 都附属于foo(..) 的作用域气泡,因此无法从foo(..) 的外部
对它们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的
代码会导致ReferenceError 错误:

bar(); // 失败
console.log( a, b, c ); // 三个全都失败

但是,这些标识符(a、b、c、foo 和bar)在foo(..) 的内部都是可以被访问的,同样在
bar(..) 内部也可以被访问(假设bar(..) 内部没有同名的标识符声明)。
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复
用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用
JavaScript 变量可以根据需要改变值类型的“动态”特性。
但与此同时,如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意
想不到的问题。

3.2 隐藏内部实现

对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来
一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际
上就是把这些代码“隐藏”起来了。
实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任
何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的
作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域
来“隐藏”它们。

为什么“隐藏”变量和函数是一个有用的技术?
有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来
的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必
要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API 设计。
这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作
用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小
特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确
的代码应该是可以阻止对这些变量或函数进行访问的。
例如:

function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15

在这个代码片段中,变量b 和函数doSomethingElse(..) 应该是doSomething(..) 内部具体
实现的“私有”内容。给予外部作用域对b 和doSomethingElse(..) 的“访问权限”不仅
没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用,
从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内
容隐藏在doSomething(..) 内部,例如:

function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15

现在,b 和doSomethingElse(..) 都无法从外部被访问,而只能被doSomething(..) 所控制。
功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会
依此进行实现。

  • 规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,
两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致
变量的值被意外覆盖。
例如:

function foo() {
function bar(a) {
i = 3; // 修改for 循环所属作用域中的i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();

bar(..) 内部的赋值表达式i = 3 意外地覆盖了声明在foo(..) 内部for 循环中的i。在这
个例子中将会导致无限循环,因为i 被固定设置为3,永远满足小于10 这个条件。
bar(..) 内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以,var i = 3;
就可以满足这个需求(同时会为i 声明一个前面提到过的“遮蔽变量”)。另外一种方法是
采用一个完全不同的标识符名称,比如var j = 3;。但是软件设计在某种情况下可能自然
而然地要求使用同样的标识符名称,因此在这种情况下使用作用域来“隐藏”内部声明是
唯一的最佳选择。

  1. 全局命名空间

变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它
们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象
被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属
性,而不是将自己的标识符暴漏在顶级的词法作用域中。
例如:

var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
  1. 模块管理

另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来
使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器
的机制将库的标识符显式地导入到另外一个特定的作用域中。
显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用
域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域
中,这样可以有效规避掉所有的意外冲突。
因此,只要你愿意,即使不使用任何依赖管理工具也可以实现相同的功效。第5 章会介绍
模块模式的详细内容。

3.3 函数作用域

我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐
藏”起来,外部作用域无法访问包装函数内部的任何内容。
例如:

var a = 2;
function foo() { // <-- 添加这一行
var a = 3;
console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先,
必须声明一个具名函数foo(),意味着foo 这个名称本身“污染”了所在作用域(在这个
例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其
中的代码。
如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,
这将会更加理想。
幸好,JavaScript 提供了能够同时解决这两个问题的方案、

var a = 2;
(function foo(){ // <-- 添加这一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2

接下来我们分别介绍这里发生的事情。
首先,包装函数的声明以(function… 而不仅是以function… 开始。尽管看上去这并不
是一个很显眼的细节,但实际上却是非常重要的区别。函数会被当作函数表达式而不是一
个标准的函数声明来处理。

区分函数声明和表达式最简单的方法是看function 关键字出现在声明中的位
置(不仅仅是一行代码,而是整个声明中的位置)。如果function 是声明中
的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
比较一下前面两个代码片段。第一个片段中foo 被绑定在所在作用域中,可以直接通过
foo() 来调用它。第二个片段中foo 被绑定在函数表达式自身的函数中而不是所在作用域中。
换句话说,(function foo(){ .. }) 作为函数表达式意味着foo 只能在.. 所代表的位置中
被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作
用域。

3.3.1 匿名和具名

对于函数表达式你最熟悉的场景可能就是回调参数了,比如:

setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );

这叫作匿名函数表达式,因为function().. 没有名称标识符。函数表达式可以是匿名的,
而函数声明则不可以省略函数名——在JavaScript 的语法中这是非法的。
匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是
它也有几个缺点需要考虑。

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  • 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee 引用,
    比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑
    自身。
  • 匿名函数省略了对于代码可读性/ 可理解性很重要的函数名。一个描述性的名称可以让
    代码不言自明。

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函
数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
3.3.2 立即执行函数表达式
var a = 2;
(function foo() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

由于函数被包含在一对( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个
( ) 可以立即执行这个函数,比如(function foo(){ .. })()。第一个( ) 将函数变成表
达式,第二个( ) 执行了这个函数。
这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式
(Immediately Invoked Function Expression);
函数名对IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使
用具名函数的IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值
得推广的实践。

var a = 2;
(function IIFE() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

相较于传统的IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。仔
细观察其中的区别。第一种形式中函数表达式被包含在( ) 中,然后在后面用另一个() 括
号来调用。第二种形式中用来调用的() 括号被移进了用来包装的( ) 括号中。
这两种形式在功能上是一致的。选择哪个全凭个人喜好。
IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。
例如:

var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2

我们将window 对象的引用传递进去,但将参数命名为global,因此在代码风格上对全局
对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传
递任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是非
常有帮助的。
这个模式的另外一个应用场景是解决undefined 标识符的默认值被错误覆盖导致的异常(虽
然不常见)。将一个参数命名为undefined,但是在对应的位置不传入任何值,这样就可以
保证在代码块中undefined 标识符的值真的是undefined:

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做!
(function IIFE( undefined ) {
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE
执行之后当作参数传递进去。这种模式在UMD(Universal Module Definition)项目中被广
泛使用。尽管这种模式略显冗长,但有些人认为它更易理解。

var a = 2;
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});

函数表达式def 定义在片段的第二部分,然后当作参数(这个参数也叫作def)被传递进
IIFE 函数定义的第一部分中。最后,参数def(也就是传递进去的函数)被调用,并将
window 传入当作global 参数的值。

3.4 块作用域

尽管函数作用域是最常见的作用域单元,当然也是现行大多数JavaScript 中最普遍的设计
方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可
以实现维护起来更加优秀、简洁的代码。
除JavaScript 外的很多编程语言都支持块作用域,因此其他语言的开发者对于相关的思维
方式会很熟悉,但是对于主要使用JavaScript 的开发者来说,这个概念会很陌生。
尽管你可能连一行带有块作用域风格的代码都没有写过,但对下面这种很常见的JavaScript
代码一定很熟悉:

for (var i=0; i<10; i++) {
console.log( i );
}

我们在for 循环的头部直接定义了变量i,通常是因为只想在for 循环内部的上下文中使
用i,而忽略了i 会被绑定在外部作用域(函数或全局)中的事实。
这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并最大限度地本地
化。另外一个例子:

var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}

bar 变量仅在if 声明的上下文中使用,因此如果能将它声明在if 块内部中会是一个很有
意义的事情。但是,当使用var 声明变量时,它写在哪里都是一样的,因为它们最终都会
属于外部作用域。这段代码是为了风格更易读而伪装出的形式上的块作用域,如果使用这
种形式,要确保没在作用域其他地方意外地使用bar 只能依靠自觉性。
块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息
扩展为在块中隐藏信息。
再次考虑for 循环的例子:

for (var i=0; i<10; i++) {
console.log( i );
}

为什么要把一个只在for 循环内部使用(至少是应该只在内部使用)的变量i 污染到整个
函数作用域中呢?
更重要的是,开发者需要检查自己的代码,以避免在作用范围外意外地使用(或复用)某
些变量,如果在错误的地方使用变量将导致未知变量的异常。变量i 的块作用域(如果存
在的话)将使得其只能在for 循环内部使用,如果在函数中其他地方使用会导致错误。这
对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助。
但可惜,表面上看JavaScript 并没有块作用域的相关功能。
除非你更加深入地研究。

3.4.1 with

我们在第2 章讨论过with 关键字。它不仅是一个难于理解的结构,同时也是块作用域的一
个例子(块作用域的一种形式),用with 从对象中创建出的作用域仅在with 声明中而非外
部作用域中有效。

3.4.2 try/catch

非常少有人会注意到JavaScript 的ES3 规范中规定try/catch 的catch 分句会创建一个块作
用域,其中声明的变量仅在catch 内部有效。
例如:

try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found

正如你所看到的,err 仅存在catch 分句内部,当试图从别处引用它时会抛出错误。

尽管这个行为已经被标准化,并且被大部分的标准JavaScript 环境(除了老
版本的IE 浏览器)所支持,但是当同一个作用域中的两个或多个catch 分句
用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告。
实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部,
但是静态检查工具还是会很烦人地发出警告。
为了避免这个不必要的警告,很多开发者会将catch 的参数命名为err1、
err2 等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。

也许catch 分句会创建块作用域这件事看起来像教条的学院理论一样没什么用处,但是查
看附录B 就会发现一些很有用的信息。

3.4.3 let

到目前为止,我们知道JavaScript 在暴露块作用域的功能中有一些奇怪的行为。如果仅仅
是这样,那么JavaScript 开发者多年来也就不会将块作用域当作非常有用的机制来使用了。
幸好,ES6 改变了现状,引入了新的let 关键字,提供了除var 以外的另一种变量声明方式。
let 关键字可以将变量绑定到所在的任意作用域中(通常是{ .. } 内部)。换句话说,let
为其声明的变量隐式地了所在的块作用域。

var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError

用let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过
程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将
其包含在其他的块中,就会导致代码变得混乱。
为块作用域显式地创建块可以部分解决这个问题,使变量的附属关系变得更加清晰。通常
来讲,显式的代码优于隐式或一些精巧但不清晰的代码。显式的块作用域风格非常容易书
写,并且和其他语言中块作用域的工作原理一致:

var foo = true;
if (foo) {
{ // <-- 显式的快
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError

只要声明是有效的,在声明中的任意位置都可以使用{ .. } 括号来为let 创建一个用于绑
定的块。在这个例子中,我们在if 声明内部显式地创建了一个块,如果需要对其进行重
构,整个块都可以被方便地移动而不会对外部if 声明的位置和语义产生任何影响。

关于另外一种显式的块作用域表达式的内容,请查看附录B。

在第4 章,我们会讨论提升,提升是指声明会被视为存在于其所出现的作用域的整个范围内。
但是使用let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不
“存在”。

{
console.log( bar ); // ReferenceError!
let bar = 2;
}
  1. 垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一
下,而内部的实现原理,也就是闭包的机制会在第5 章详细解释。
考虑以下代码:

function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );

click 函数的点击回调并不需要someReallyBigData 变量。理论上这意味着当process(..) 执
行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于click 函数形成
了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体
实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存someReallyBigData 了:

function process(data) {
// 在这里做点有趣的事情
}
// 在这个块中定义的内容可以销毁了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked"

为变量显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你
的代码工具箱中了。

  1. let循环

一个let 可以发挥优势的典型例子就是之前讨论的for 循环。

for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError

for 循环头部的let 不仅将i 绑定到了for 循环的块中,事实上它将其重新绑定到了循环
的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为:

{
let j;
for (j=0; j<10; j++) {
let i = j; // 每个迭代重新绑定!
console.log( i );
}
}

每个迭代进行重新绑定的原因非常有趣,我们会在第5 章讨论闭包时进行说明。
由于let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域),
当代码中存在对于函数作用域中var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用
let 来替代var 则需要在代码重构的过程中付出额外的精力。
考虑以下代码:

var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}
// ...
}

这段代码可以简单地被重构成下面的同等形式:

var foo = true, baz = 10;
if (foo) {
var bar = 3;
// ...
}
if (baz > bar) {
console.log( baz );
}

但是在使用块级作用域的变量时需要注意以下变化:

var foo = true, baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) { // <-- 移动代码时不要忘了bar!
console.log( baz );
}
}

参考附录B,其中介绍了另外一种块作用域形式,可以用更健壮的方式实现目的,并且写
出的代码更易维护和重构。

3.4.4 const

除了let 以外,ES6 还引入了const,同样可以用来创建块作用域变量,但其值是固定的
(常量)。之后任何试图修改值的操作都会引起错误。

var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在if 中的块作用域常量
a = 3; // 正常!
b = 4; // 错误!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
3.5 小结

函数是JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会
在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,
也可以属于某个代码块(通常指{ .. } 内部)。
从ES3 开始,try/catch 结构在catch 分句中具有块作用域。
在ES6 中引入了let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if
(..) { let a = 2; } 会声明一个劫持了if 的{ .. } 块的变量,并且将变量添加到这个块
中。
有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开
发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

第4章 提升

到现在为止,你应该已经很熟悉作用域的概念,以及根据声明的位置和方式将变量分配给
作用域的相关原理了。函数作用域和块作用域的行为是一样的,可以总结为:任何声明在
某个作用域内的变量,都将附属于这个作用域。
但是作用域同其中的变量声明出现的位置有某种微妙的联系,而这个细节正是我们将要讨
论的内容。

4.1 先有鸡还是先有蛋

直觉上会认为JavaScript 代码在执行时是由上到下一行一行执行的。但实际上这并不完全
正确,有一种特殊情况会导致这个假设是错误的。
考虑以下代码:

a = 2;
var a;
console.log( a );

你认为console.log(..) 声明会输出什么呢?
很多开发者会认为是undefined,因为var a 声明在a = 2 之后,他们自然而然地认为变量
被重新赋值了,因此会被赋予默认值undefined。但是,真正的输出结果是2。
考虑另外一段代码:

console.log( a );
var a = 2;

鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,你可能会认为这个代码片
段也会有同样的行为而输出2。还有人可能会认为,由于变量a 在使用前没有先进行声明,
因此会抛出ReferenceError 异常。
不幸的是两种猜测都是不对的。输出来的会是undefined。
那么到底发生了什么?看起来我们面对的是一个先有鸡还是先有蛋的问题。到底是声明
(蛋)在前,还是赋值(鸡)在前?

4.2 编译器再度来袭

为了搞明白这个问题,我们需要回顾一下第1 章中关于编译器的内容。回忆一下,引擎会
在解释JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的
声明,并用合适的作用域将它们关联起来。第2 章中展示了这个机制,也正是词法作用域
的核心内容。
因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先
被处理。
当你看到var a = 2; 时,可能会认为这是一个声明。但JavaScript 实际上会将其看成两个
声明:var a; 和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在
原地等待执行阶段。
我们的第一个代码片段会以如下形式进行处理:

var a;
a = 2;
console.log( a );

其中第一部分是编译,而第二部分是执行。
类似地,我们的第二个代码片段实际是按照以下流程处理的:

var a;
console.log( a );
a = 2;

因此,打个比方,这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”
到了最上面。这个过程就叫作提升。
换句话说,先有蛋(声明)后有鸡(赋值)。

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变
了代码执行的顺序,会造成非常严重的破坏。

foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}

foo 函数的声明(这个例子还包括实际函数的隐含值)被提升了,因此第一行中的调用可
以正常执行。

另外值得注意的是,每个作用域都会进行提升操作。尽管前面大部分的代码片段已经简化
了(因为它们只包含全局作用域),而我们正在讨论的foo(..) 函数自身也会在内部对var
a 进行提升(显然并不是提升到了整个程序的最上方)。因此这段代码实际上会被理解为下
面的形式:

function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();

可以看到,函数声明会被提升,但是函数表达式却不会被提升。

foo(); // 不是ReferenceError, 而是TypeError!
var foo = function bar() {
// ...
};

这段程序中的变量标识符foo() 被提升并分配给所在作用域(在这里是全局作用域),因此
foo() 不会导致ReferenceError。但是foo 此时并没有赋值(如果它是一个函数声明而不
是函数表达式,那么就会赋值)。foo() 由于对undefined 值进行函数调用而导致非法操作,
因此抛出TypeError 异常。
同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中
使用:

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};

这个代码片段经过提升后,实际上会被理解为以下形式:

var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
4.3 函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个
“重复”声明的代码中)是函数会首先被提升,然后才是变量。
考虑以下代码:

foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};

会输出1 而不是2 !这个代码片段会被引擎理解为如下形式:

function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};

注意,var foo 尽管出现在function foo()… 的声明之前,但它是重复的声明(因此被忽
略了),因为函数声明会被提升到普通变量之前。
尽管重复的var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}

虽然这些听起来都是些无用的学院理论,但是它说明了在同一个作用域中进行重复定义是
非常糟糕的,而且经常会导致各种奇怪的问题。
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代
码暗示的那样可以被条件判断所控制:

foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}

但是需要注意这个行为并不可靠,在JavaScript 未来的版本中有可能发生改变,因此应该
尽可能避免在块内部声明函数。

4.4 小结

我们习惯将var a = 2; 看作一个声明,而实际上JavaScript 引擎并不这么认为。它将var a
和a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。
可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的
最顶端,这个过程被称为提升。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
要注意避免重复声明,特别是当普通的var 声明和函数声明混合在一起的时候,否则会引
起很多危险的问题!

第5章 作用域闭包

接下来的内容需要对作用域工作原理相关的基础知识有非常深入的理解。
我们将注意力转移到这门语言中一个非常重要但又难以掌握,近乎神话的概念上:闭包。
如果你了解了之前关于词法作用域的讨论,那么闭包的概念几乎是不言自明的。魔术师的
幕布后藏着一个人,我们将要揭开他的伪装。我可没说这个人是Crockford1 !
在继续学习之前,如果你还是对词法作用域相关内容有疑问,可以重新回顾一下第2 章中
的相关内容,现在是个好机会。

5.1 启示

对于那些有一点JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看
作是某种意义上的重生,但是需要付出非常多的努力和牺牲才能理解这个概念。
回忆我前几年的时光,大量使用JavaScript 但却完全不理解闭包是什么。总是感觉这门语
言有其隐蔽的一面,如果能够掌握将会功力大涨,但讽刺的是我始终无法掌握其中的门
道。还记得我曾经大量阅读早期框架的源码,试图能够理解闭包的工作原理。现在还能回
忆起我的脑海中第一次浮现出关于“模块模式”相关概念时的激动心情。
那时我无法理解并且倾尽数年心血来探索的,也就是我马上要传授给你的秘诀:JavaScript
中闭包无处不在,你只需要能够识别并拥抱它。 闭包并不是一个需要学习新的语法或模式
才能使用的工具,它也不是一件必须接受像Luke2 一样的原力训练才能使用和掌握的武器。
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意
识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿
来识别、拥抱和影响闭包的思维环境。
最后你恍然大悟:原来在我的代码中已经到处都是闭包了,现在我终于能理解它们了。理
解闭包就好像Neo3 第一次见到矩阵4 一样。

5.2 实质问题

好了,夸张和浮夸的电影比喻已经够多了。
下面是直接了当的定义,你需要掌握它才能理解和识别闭包:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用
域之外执行。
下面用一些代码来解释这个定义。

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() 函数)赋值给变量baz 并调用baz(),实
际上只是通过不同的标识符引用调用了内部的函数bar()。
bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方
执行。
在foo() 执行后,通常会期待foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃
圾回收器用来释放不再使用的内存空间。由于看上去foo() 的内容不会再被使用,所以很
自然地会考虑对其进行回收。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此
没有被回收。谁在使用这个内部作用域?原来是bar() 本身在使用。
拜bar() 所声明的位置所赐,它拥有涵盖foo() 内部作用域的闭包,使得该作用域能够一
直存活,以供bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
因此,在几微秒之后变量baz 被实际调用(调用内部函数bar),不出意料它可以访问定义
时的词法作用域,因此它也可以如预期般访问变量a。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的
词法作用域。
当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到
闭包。

function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包!
}

把内部函数baz 传递给bar,当调用这个内部函数时(现在叫作fn),它涵盖的foo() 内部
作用域的闭包就可以观察到了,因为它能够访问a。
传递函数当然也可以是间接的。

var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将baz 分配给全局变量
}
function bar() {
fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

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

5.3 现在我懂了

前面的代码片段有点死板,并且为了解释如何使用闭包而人为地在结构上进行了修饰。但
我保证闭包绝不仅仅是一个好玩的玩具。你已经写过的代码中一定到处都是闭包的身影。
现在让我们来搞懂这个事实。

function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

将一个内部函数(名为timer)传递给setTimeout(..)。timer 具有涵盖wait(..) 作用域
的闭包,因此还保有对变量message 的引用。
wait(..) 执行1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有wait(..)
作用域的闭包。
深入到引擎的内部原理中,内置的工具函数setTimeout(..) 持有对一个参数的引用,这个
参数也许叫作fn 或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是
内部的timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包。
或者,如果你很熟悉jQuery(或者其他能说明这个问题的JavaScript 框架),可以思考下面
的代码:

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 或者任何其他的异步(或者同步)任务中,只要使
用了回调函数,实际上就是在使用闭包!

第3 章介绍了IIFE 模式。通常认为IIFE 是典型的闭包例子,但根据先前对
闭包的定义,我并不是很同意这个观点。

var a = 2;
(function IIFE() {
console.log( a );
})();

虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中
的IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而
外部作用域,也就是全局作用域也持有a)。a 是通过普通的词法作用域查找而非闭包被发
现的。
尽管技术上来讲,闭包是发生在定义时的,但并不非常明显,就好像六祖慧能所说:“既
非风动,亦非幡动,仁者心动耳。”5。
尽管IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建
可以被封闭起来的闭包的工具。因此IIFE 的确同闭包息息相关,即使本身并不会真的使用
闭包。
亲爱的读者,现在把书放下,我有一个任务要给你。打开你最近写的JavaScript 代码,找
到其中的函数类型的值并指出哪里已经使用了闭包,即使你以前可能并不知道这就是
闭包。
等你呦!
现在你懂了吧!

5.4 循环和闭包

要说明闭包,for 循环是最常见的例子。

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

由于很多开发者对闭包的概念认识得并不是很清楚,因此当循环内部包含函
数定义时,代码格式检查器经常发出警告。我们在这里介绍如何才能正确地
使用闭包并发挥它的威力,但是代码格式检查器并没有那么灵敏,它会假设
你并不真正了解自己在做什么,所以无论如何都会发出警告。

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

这是为什么?

首先解释6 是从哪里来的。这个循环的终止条件是i 不再<=5。条件首次成立时i 的值是
6。因此,输出显示的是循环结束时i 的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,
当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循
环结束后才会被执行,因此会每次输出一个6 出来。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一
致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i 的副本。但是
根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,
但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。
这样说的话,当然所有函数共享一个i 的引用。循环结构让我们误以为背后还有更复杂的
机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,
那它同这段代码是完全等价的。
下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭
代都需要一个闭包作用域。
第3 章介绍过,IIFE 会通过声明并立即执行一个函数来创建作用域。
我们来试一下:

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

这样能行吗?试试吧,我等着你。
我不卖关子了。这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确
每个延迟函数都会将IIFE 在每次迭代中创建的作用域封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的IIFE 只是一
个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中储存i 的值:

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

行了!它能正常工作了!。
可以对这段代码进行一些改进:

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

当然,这些IIFE 也不过就是函数,因此我们可以将i 传递进去,如果愿意的话可以将变量
名定为j,当然也可以还叫作i。无论如何这段代码现在可以工作了。
在迭代内使用IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的
作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
问题解决啦!

  • 重返块作用域

仔细思考我们对前面的解决方案的分析。我们使用IIFE 在每次迭代时都创建一个新的作用
域。换句话说,每次迭代我们都需要一个块作用域。第3 章介绍了let 声明,可以用来劫
持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码
就可以正常运行了:

for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}

但是,这还不是全部!(我用Bob Barker6 的声音说道)for 循环头部的let 声明还会有一
个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随
后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

很酷是吧?块作用域和闭包联手便可天下无敌。不知道你是什么情况,反正这个功能让我
成为了一名快乐的JavaScript 程序员。

5.5 模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一
起来研究其中最强大的一个:模块。

function foo() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
}

正如在这段代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量something
和another,以及doSomething() 和doAnother() 两个内部函数,它们的词法作用域(而这
就是闭包)也就是foo() 的内部作用域。
接下来考虑以下代码:

function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

这个模式在JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,
这里展示的是其变体。
我们仔细研究一下这些代码。
首先,CoolModule() 只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行
外部函数,内部作用域和闭包都无法被创建。
其次,CoolModule() 返回一个用对象字面量语法{ key: value, … } 来表示的对象。这
个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐
藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。
这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API 中的
属性方法,比如foo.doSomething()。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函
数。jQuery 就是一个很好的例子。jQuery 和$ 标识符就是jQuery 模块的公
共API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属
性)。

doSomething() 和doAnother() 函数具有涵盖模块实例内部作用域的闭包( 通过调用
CoolModule() 实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作
用域外部时,我们已经创造了可以观察和实践闭包的条件。
如果要更简单的描述,模块模式需要具备两个必要条件。

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块
    实例)。
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并
    且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用
所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
上一个示例代码中有一个叫作CoolModule() 的独立的模块创建器,可以被调用任意多次,
每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的
改进来实现单例模式:

var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

我们将模块函数转换成了IIFE(参见第3 章),立即调用这个函数并将返回值直接赋值给
单例的模块实例标识符foo。
模块也是普通的函数,因此可以接受参数:

function CoolModule(id) {
function identify() {
console.log( id );
}
return {
identify: identify
};
}
var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

模块模式另一个简单但强大的变化用法是,命名将要作为公共API 返回的对象:

var foo = (function CoolModule(id) {
function change() {
// 修改公共API
publicAPI.identify = identify2;
}
function identify1() {
console.log( id );
}
function identify2() {
console.log( id.toUpperCase() );
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通过在模块实例的内部保留对公共API 对象的内部引用,可以从内部对模块实例进行修
改,包括添加或删除方法和属性,以及修改它们的值。

5.5.1 现代的模块机制

大多数模块依赖加载器/ 管理器本质上都是将这种模块定义封装进一个友好的API。这里
并不会研究某个具体的库,为了宏观了解我会简单地介绍一些核心概念:

var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get
};
})();

这段代码的核心是modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装
函数(可以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管
理的模块列表中。
下面展示了如何使用它来定义模块:

MyModules.define( "bar", [], function() {
function hello(who) {
return "Let me introduce: " + who;
}
return {
hello: hello
};
} );
MyModules.define( "foo", ["bar"], function(bar) {
var hungry = "hippo";
function awesome() {
console.log( bar.hello( hungry ).toUpperCase() );
}
return {
awesome: awesome
};
} );
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(
bar.hello( "hippo" )
); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

“foo” 和”bar” 模块都是通过一个返回公共API 的函数来定义的。”foo” 甚至接受”bar” 的
示例作为依赖参数,并能相应地使用它。
为我们自己着想,应该多花一点时间来研究这些示例代码并完全理解闭包的作用吧。最重
要的是要理解模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个
特点:为函数定义引入包装函数,并保证它的返回值和模块的API 保持一致。
换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

5.5.2 未来的模块机制

ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立
的模块来处理。每个模块都可以导入其他模块或特定的API 成员,同样也可以导出自己的
API 成员。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们
的API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块
的API(参考前面关于公共API 的讨论)。
相比之下,ES6 模块API 更加稳定(API 不会在运行时改变)。由于编辑器知
道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的API 成
员的引用是否真实存在。如果API 引用并不存在,编译器会在运行时抛出一
个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

ES6 的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览
器或引擎有一个默认的“模块加载器”(可以被重载,但这远超出了我们的讨论范围)可
以在导入模块时异步地加载模块文件。
考虑以下代码:

bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
foo.js
// 仅从"bar" 模块导入hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
baz.js
// 导入完整的"foo""bar" 模块
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

需要用前面两个代码片段中的内容分别创建文件foo.js 和bar.js。然后如第三
个代码片段中展示的那样,bar.js 中的程序会加载或导入这两个模块并使用
它们。

import 可以将一个模块中的一个或多个API 导入到当前作用域中,并分别绑定在一个变量
上(在我们的例子里是hello)。module 会将整个模块的API 导入并绑定到一个变量上(在
我们的例子里是foo 和bar)。export 会将当前模块的一个标识符(变量、函数)导出为公
共API。这些操作可以在模块定义中根据需要使用任意多次。
模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭
包模块一样。

5.6 小结

闭包就好像从JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人
才能够到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的
词法环境中书写代码的。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时
就产生了闭包。

如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循
环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。
模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回
值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭
包。
现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用
的事!


《你不知道的JavaScript 上卷》下载地址

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值