1. 什么是作用域?
几乎所有的编程语言最基本的功能之一就是能够存储变量中的值,并且能在之后对这个值进行访问和修改。正是这种储存和访问变量的值的能力将状态引入到了程序中。
但是将变量引入程序会引起几个有意思的问题,也是需要我们认真思考的:这些变量住在哪里?或者说它们存储在哪里?更重要的是,程序在需要它们的时候如何找到它们?
1.1 Javascript 的编译原理
我们经常将 Javascript 归类为动态或者解释型语言,但是实际上 Javascript 是一门编译型语言。但是与传统的编译语言不同,Javascript 不是提前编译的,编译的结果也不能在分布式系统中进行移植。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为编译:
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)。 - 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套组成的代表了程序语法结构的树,这个树被称为抽象语法树(Abstract Syntax Tree,AST)。 - 代码生成(Code Generation)
这个过程是将 AST 转换为可执行代码的过程。
💡 与其他语言不同,Javascript 的编译过程不是发生在构建之前,大部分情况下是发生在代码执行前的几微秒,简单的说,任何 Javascript 代码片段在执行前都要进行编译(通常就在执行前)。
1.2 作用域
在理解作用域之前,我们先来介绍几个相关的概念:
- 引擎:从头到尾负责整个 Javascript 程序的编译和执行过程。
- 编译器:引擎的好朋友之一,负责语法分析以及代码生成等脏活累活。
- 作用域:引擎的另外一个好朋友,负责收集并维护所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
接下来我们分析一下var a = 2
这段代码在引擎、编译器以及作用域中是如何运行的。
你很可能认为这是一句声明,翻译成我们人类的语言就是:“为一个变量分配内存,将其命名为 a,然后将值 2 保存进这个变量(然而这并不完全正确)”。但是引擎却不这么看。事实上,引擎认为这里有连两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。下面我们将var a = 2
分解,看看引擎和它的朋友们是如何协同工作的。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期有所不同。
事实上编译器会进行如下处理:
- 遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。 - 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量,直到到达最外层的作用域(也就是全局作用域)为止。 - 如果引擎最终找到了 a,就会将 2 赋值给它。否则引擎就会抛出一个异常。
总结: 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在当前作用域中查找该变量,如果找到就会对其进行赋值。
1.3 作用域嵌套
我们知道,作用域是根据名称查找变量的一套规则,它用于确定当前执行的代码对变量的访问权限。实际情况中,通常需要同时顾及几个作用域。
当一个块或者函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的全局作用域为止。
考虑以下代码:
1 | function foo(a) { |
在这个例子中,函数 foo 嵌套在全局作用域中,而变量 b 也在全局作用域中声明。当 foo 函数执行时,它会在自己的作用域中查找变量 b,但找不到,因此会继续查找外层的全局作用域,找到变量 b,并将其赋值给 a。
让我们把这段代码形象化的用引擎和作用域之间的对话来描述:
- 引擎:foo 的作用域兄弟,你见过变量 b 吗?我需要对它进行 RHS 引用查询。
- 作用域:听都没听过,走开。
- 引擎:foo 的上一级作用域兄弟,咦?有眼不识泰山了,原来你是全剧作用域大哥,太好了。你见过变量 b 吗?我需要对它进行 RHS 引用查询。
- 作用域:当然了,给你吧。
遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找,如果找不到就会向上一级作用域继续查找。当抵达最外层的全局作用域时,无论找到与否都会停止。
💡 关于 LHS 查询和 RHS 查询:RHS 查询与简单的查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其进行赋值。在概念上最好理解为:赋值操作的目标是谁?(LHS)。谁是赋值操作的源头?(RHS)。
1.4 异常
为什么区分LHS
和RHS
很重要?
因为变量在还没有声明(在任何作用域中都无法找到该变量)时,这两种查询的行为是不一样的。考虑如下代码:
1 | function foo() { |
在这个例子中,第一次对 b 进行的 RHS 查询是无法找到该变量的。也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。
如果 RHS 查询在所有嵌套的作用域中都无法找到所需的变量,引擎就会抛出ReferenceError
异常。请牢记,ReferenceError
是一种非常重要的运行时异常类型。
相较之下,当引擎执行 LHS 查询时,如果一直查询到了顶层作用域(全局作用域)中仍然无法找到目标变量,全局作用域中就会创建一个具有改名称的变量,并将其返还给引擎,前提是程序运行在非严格模式下。
ES5 引入了“严格模式”,严格模式禁止自动或者隐式地创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,而是引擎会抛出同 RHS 查询失败时类似的ReferenceError
异常。
此外,如果 RHS 查询找到了一个变量,但是当你尝试对这个变量的值进行不合理的操作时,比如试图对一个非函数类型的值进行函数调用,或者对一个null
或者undefined
值进行属性访问,引擎也会抛出另外一种类型的异常,叫做TypeError
。
💡
ReferenceError
同作用域判别失败相关,而TypeError
则代表了作用域判别成功了,但是对变量的操作是非法或者不合理的。
2. 词法作用域
我们将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
作用域共有两种主要的工作类型,第一种是最为普遍的,被大多数编程语言所采用,叫做词法作用域(Lexical Scope)。另外一种叫做动态作用域(Dynamic Scope),它是根据运行时环境的变化而变化的作用域。
2.1 词法阶段
大部分标准语言编译器的第一个工作阶段叫做词法分析(也叫做单词化/Tokenizing),词法化的过程就是将源代码字符串分解成一个个的词法单元(Token)。
简单的说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此,当词法分析器处理代码时会保持作用域不变。
举个例子,考虑以下代码:
1 | function foo(a) { |
在这个例子中一共有三个逐级嵌套的作用域:
- ❶ 全局作用域(Global Scope),其中只有一个标识符: foo;
- ❷ 函数 foo 的作用域(Function Scope),其中有三个标识符:a, b, bar;
- ❸ 函数 bar 的作用域(Function Scope),其中有两个标识符:c。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的同名标识符)。作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者向上进行,直到遇见第一个匹配的标识符为止。
⚠️ 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只有函数被声明时所处的位置决定。
2.2 欺骗词法作用域
如果词法作用域完全由写代码期间函数所声明的位置决定,有没有一种方法可以在运行时来修改(或者说“欺骗”)词法作用域呢?
JavaScript 中有两种机制可以实现这个目的,但是社区普遍认为在代码中使用这两种机制并不是什么好主意,最重要的一点是:欺骗词法作用域会导致性能下降。
2.2.1 eval
eval()
函数可以接受一个字符串作为参数,并将其中的内容视为好像在书写时就存在于程序中的那个位置一样。换句话说,可以在你写的代码中用程序在运行时生成代码并运行,就好像代码就是写在那个位置一样。
在执行eval()
之后的代码时,引擎并不知道也不在意前面的代码是以动态的形式插入进来的,引擎只会如往常一样进行词法作用域查找。
考虑一下代码:
1 | function foo(str, a) { |
在这个例子中,eval()
函数将字符串var b = 3
作为代码插入到了函数 foo
中,引擎会将其视为在函数 foo
声明时就存在的那个位置。
因此,在执行eval()
之后的代码时,引擎会认为变量 b 的值是 3,而不是 2, 这段代码对已经存在的foo
的词法作用域进行了修改。
默认情况下,如果eval()
中执行的代码包含一个或者多个声明(无论是变量还是函数),就会对eval()
所处的词法作用域进行修改。无论在何种情况下,eval()
都可以在运行时修改书写期的词法作用域。
2.2.2 with
JavaScript 中另外一个用来欺骗词法作用域的功能是with
关键字,with
被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。比如下面的代码:
1 | var obj = { |
但是这实际上并不仅仅是为了方便的访问对象属性,比如如下代码:
1 | function foo(obj) { |
在这个例子中,当我们将 o1 传递进去,a = 2
赋值操作通过 LHS 查询找到了o1.a
并将 2 赋值给它,这在随后的console.log(o1.a)
可以体现。而当 o2 被传递进去时,o2 并没有 a 属性,因此a = 2
的赋值操作通过 LHS 查询失败,引擎会在全局作用域中创建一个新的变量a
,并将 2 赋值给它。而 o2 并没有a
属性,因此console.log(o2.a)
会返回undefined
。
with
可以将一个没有或者有多个属性的对象处理为一个完全隔离的词法作用域,因此,这个对象的属性也会被处理为定义在这个词法作用域中的词法标识符。
eval()
函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with
实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
⚠️ 虽然
eval()
和with
可以欺骗词法作用域,但是它并不推荐使用,因为它会导致代码难以理解和维护。另外一个不推荐使用的原因是会被严格模式所影响。严格模式下with
被完全禁止,而eval()
在严格模式下也会被禁止。
2.2.3 性能
为什么说eval()
和with
会导致性能下降呢?
JavaScript 引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中(运行时)快速的找到标识符。
但是如果引擎在执行过程中发现了eval()
或者with
,它只能简单的假设所有关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval()
会接受到什么样的代码,运行这些代码又会对作用域进行什么修改,也无法知道传递给with
用来创建的新的词法作用域的对象内容是什么。
最悲观的情况是如果出现了eval()
或者with
,所有的优化肯能都是无意义的,因此最佳实践就是不要使用它们。
3. 函数作用域和块作用域
我们可以把作用域理解为一个封闭的盒子或者一个气泡,它们可以嵌套,每个盒子(气泡)里包含了标识符(变量和函数),但是,究竟是什么生成了一个新的盒子?只有函数会生成新的盒子吗?JavaScrip 其他结构能生成作用域气泡吗?
3.1 函数中的作用域
JavaScript 具有基于函数的作用域,每声明一个函数都会为其自身创建一个作用域气泡,而其他结构都不会创建作用域气泡。但事实上这不完全正确。考虑以下代码:
1 | function foo(a) { |
在这个例子中,函数foo
的作用域气泡中包含了标识符 a、b、bar 和 c。无论标识符声明出现在作用域的何处,这个标识符所代表的变量或者函数都将附属域所处作用域的气泡中。
函数bar
拥有自己的作用域气泡,全局作用域也有自己的作用域气泡,它只包含标识符 foo
。由于标识符 a、b、bar 和 c,都附属于foo
的作用域气泡,因此无法从外部对它们进行访问。也就是说无法从全局作用域中访问它们,下面的代码会导致ReferenceError
异常:
1 | bar(); // ReferenceError |
但是这些标识符可以被foo
内部的代码访问到,也可以被bar
内部的代码访问到(前提是bar
内部没有声明同名的标识符)。
总结一下:函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(在嵌套的作用域中也可以使用)。
3.2 隐藏内部实现
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但是反过来也可以这么想:从所写的代码中挑选出任意一个片段,然后用函数声明对它进行包装,实际上就是把这段代码隐藏起来了。
实际产生的结果就是在这段代码的周围建立了一个作用域气泡(盒子),这段代码中的任何声明(变量或函数)都将绑定在这个新创建的作用域气泡中,而不是先前所在的作用域中,外部代码无法访问到这些变量。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域“隐藏”它们。
3.2.1 最小授权原则
隐藏变量和函数是一个非常有用的技术。在最小授权(也叫最小暴露)原则中,在软件设计时,应该最小限度的暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或者对象的 API 设计。
如果所有变量和函数都在全局作用域中,当然可以在所有内部嵌套的作用域中都访问到它们,但这样也会破坏最小暴露原则,因为可能活暴露过多的变量或函数,而它们本该是私有的。举个例子:
1 | function doSomething(a) { |
在这个代码片段中,变量b
和函数doSomethingElse
应该是doSomething()
函数内部实现的“私有”内容。给予外部作用域对它们的访问权限不仅没有必要,而且可能是危险的,因为它们可能被有意或者无意地以非预期的方式使用,从而导致超出了doSomething()
的适用条件。更“合理”的设计是把它们隐藏在doSomething()
的内部,比如:
1 | function doSomething(a) { |
3.2.2 规避冲突
“隐藏”作作用域中的变量和函数的另外一个好处是:可以避免同名标识符之间的冲突。 两个标识符可能名字相同但是用途不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
举个例子:
1 | function foo() { |
for
循环中的var i = 0
声明了一个变量 i,它位于foo
的作用域中,bar
函数内部的赋值表达式 i = 3
,首先会在当前bar
的作用域中查找 i,由于bar
内部没有声明 i,因此会继续向上查找,在foo
的作用域中查找了 i,于是 i 的值被修改为 3,因此 bar 函数里的这个对 i 的赋值操作实际上修改了 for 循环中的 i 的值,因此导致无限循环。
其实bar()
内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以,甚至同样适用var i = 3;
也可以,由于同名的标识符会产生“遮蔽效应”,因此bar()
函数使用的是自己内部作用域中的 i, 而不会修改外层foo()
函数中的 i。
3.2.3 全局命名空间
变量冲突的一个典型的例子存在于全局作用域中。尤其是当程序加载了第三方库时,如果第三方库没有妥善的将内部的私有变量或函数隐藏起来,就会很容易引发冲突。
因此这些第三方库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符直接暴露在顶级的词法作用域中。
举个例子,jQuery 库的命名空间是$
,它将自己的所有功能都绑定在这个对象上,而不将自己的标识符直接暴露在全局作用域中。
3.3 函数作用域
我们知道,在任意的代码片段外部添加包装函数,可以将内部的变量和函数“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。如下所示:
1 | // 全局作用域 |
这种技术虽说可以解决一些问题,但是并不理想。
- 首先你必须声明一个函数
foo
,这意味着foo
这个名字本身就“污染”了所在的作用域; - 其次你必须显式的通过函数名
foo()
调用这个函数,才能运行里面的代码。
如果函数不需要函数名,或者说至少函数名不会污染所在的作用域,并且能够自动运行就好了。JavaScript 提供了一种方案:
1 | var a = 2; |
可以看到,函数声明被()包裹了起来,这是一个非常重要的区别,此时函数会被当作表达式而不是一个标准的函数声明来处理。函数声明和函数表达式最重要的区别是它们的名称标识符将会绑定在何处。
比较一下之前的两个代码片段,第一个片段中的foo
被绑定在声明它的那个作用域,也就是全局作用域,因此我们可以直接通过foo()
来调用它,而第二个片段中的foo
被绑定在一个匿名函数表达式自身的函数中,而不是所在作用域中。
换句话说,(function foo() {...})
作为函数表达式意味着foo
只能在…所代表的位置中被访问,外部作用域则不行。标识符foo
隐藏在自身中意味着不会非必要的污染外部作用域。
3.3.1 匿名和具名函数表达式
先来看一下这个代码片段:
1 | setTimeout(function () { |
注意我们传递给setTimeout()
的第一个参数是一个函数表达式,而且它没有函数名,这叫做匿名函数表达式。
⚠️ 函数表达式可以是匿名的,而函数声明则必须有函数名。
匿名函数尽管写起来简单快捷,但是有几个缺点需要考虑:
- 匿名函数在栈追踪中不会显示出有意义的函数名,因此使得调试变得困难;
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的
arguments.callee
,比如在递归中,另外一个函数需要引用自身的例子是在事件触发后事件监听器需要解绑自身; - 匿名函数省略了对于代码可读性/可理解性很重要的函数名,使得代码可读性变差。
因此,始终给函数表达式取一个有意义的名字是一个最佳实践。
3.3.2 立即执行函数表达式
1 | var a = 2; |
我们已经知道,函数被包裹在()
中,意味着它成为了一个表达式,通过在末尾加上()
,可以让函数立即执行。第一个()
将函数变成表达式,第二个()
立即执行了这个函数。社区将此种模式称为立即执行函数表达式(Immediately-Invoked Function Expression,IIFE)。
IIFE 的另外一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。举个例子:
1 | var a = 2; |
这个模式的另外一个应用场景是解决undefined
标识符的默认值被错误覆盖导致的异常。讲一个参数命名为undefined
但是在对应的位置不传入任何值,这样就可以保证在代码块种undefined
标识符的值真的就是undefined
。
1 | undefined = true; // 给程序的其他部分代码挖了一个大坑!千万不要这么做! |
3.4 块作用域
除 JavaScript 外的很多编程语言都支持块作用域,即使你对块作用域不是很了解,但是下面的代码你一定写过不少:
1 | for (var i = 0; i < 10; i++) { |
我们在 for 循环的头部直接声明了变量 i,通常来说我们可能只是想在 for 循环内部的上下文中使用这个变量,但是却忽略了一个事实:这个变量 i 实际上会被绑定在外部作用域中(函数或者全局作用域),也就是 for 循环所在的作用域中而不是内部。
这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并且最大限度的本地化。举一个例子:
1 | var foo = true; |
从代码的组织方式可以看出来bar
仅在if
声明的上下文中使用,因此上面的代码片段试图在if
块内部声明bar
变量。但是,当使用var
声明bra
变量时,它写在哪里都是一样的,因为它们最总都会被绑定在外部作用域中。
块作用域可以进一步扩展之前提到的最小暴露原则,将代码从在函数中“隐藏”信息扩展为在块中“隐藏”信息。再考虑下之前的代码片段:
1 | for (var i = 0; i < 10; i++) { |
为什么要把一个只在for
循环内部使用的变量 i 污染到整个函数作用域中呢?
3.4.1 块作用域-with
我们之前讨论过with
关键字,它不仅仅是一个难以理解的结构,同时也是块作用域的一个例子,使用with
从对象中创建的作用域仅在with
声明中有效。
3.4.2 块作用域-try/catch
很少有人注意到 JavaScript 的 ES3 规范中规定的try/catch
的catch
分句会创建一个块作用域,其中声明的变量仅在catch
内部有效。 例如:
1 | try { |
3.4.3 块作用域-let
ES6 引入了let
关键字,提供了除了var
之外的另一种变量声明的方式。let
关键字可以将变量绑定到所在的任意作用域中,通常是{...}
内部。换句话说,let
为其声明的变量隐式地劫持了所在的块作用域。
1 | var foo = true; |
用let
将变量附加在一个已经存在的块作用域上的行为是隐式的。此外关于声明提升,提升是指声明会被视为存在于其所出现的作用域的整个范围内,而不管它在代码中出现的位置。但是使用let
声明的变量不会被提升,声明的代码被运行之前,声明并不“存在”。例如:
1 | { |
(1) 垃圾收集
另一个关于块作用域非常有用的原因和闭包及内存垃圾回收机制有关, 考虑以下代码:
1 | function doSomething() { |
观察一下doSomething
的作用域中都有哪些变量?答案是:process 函数、someReallyBigData、btn、click 函数。click
函数的点击并不需要 someReallyBigData,理论上这意味着当 process(someReallyBigData)
执行完毕后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域(也就是 doSomething 函数的作用域)的闭包,在doSomething
函数执行完毕后,click 函数的引用依然存在,因此 JavaScript 引擎极有可能依然保存着这个数据。
块作用域可以打消这种顾虑,使引擎清楚的知道没有必要继续保存 someReallyBigData 了,让我们稍微调整下上面的代码:
1 | function doSomething() { |
(2) let 循环
一个 let 可以发挥优势的典型例子就是之前讨论的 for 循环。
1 | for (let i = 0; i < 10; i++) { |
for 循环头部使用 let 来定义 i,这样做不仅将 i 绑定到了 for 循环的块中,事实上它也将 i 重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
让我们换一种方式来详细解释一下每次迭代时的重新绑定行为:
1 | { |
使用 let 声明的变量附属于一个新的作用域而不是当前的函数作用域,也不属于全局作用域。考虑一下代码:
1 | var foo = true; |
这段代码等同于下面的代码:
1 | var foo = true; |
但是如果使用 let 来定义变量 bar 则有些不同:
1 | var foo = true; |
3.4.4 const
除了 let 关键字,ES6 还引入了 const 关键字,同样可以用来创建块作用域变量,但其值是固定的(常量),之后任何试图修改其值的操作都会引起错误。
1 | var foo = true; |