在第 1 章中,我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
作用域共有两种主要的工作模型:词法作用域和作动态作用域。
2.1 词法阶段
大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
存在欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域,但是这种机制可能有点难以理解。事实上,让词法作用域根据词法关系保持书写时的自然关系不变,是一个非常好的最佳实践。
考虑以下代码:1
2
3
4
5
6
7
8function 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 的位置。
注意,这里所说的气泡是严格包含的。我们并不是在讨论文氏图这种可以跨越边界的气泡。换句话说,没有任何函数的气泡可以(部分地)同时出现在两个外部作用域的气泡中,就如同没有任何函数可以部分地同时出现在两个父级函数中一样。
查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。
在上一个代码片段中,引擎执行 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 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能下降。
完~