我们将采用的学习作用域的方法,是将这个处理过程想象为一场对话。但是,谁 在进行这场对话呢?
演员:
让我们见一见处理程序 var a = 2;
时进行互动的演员吧,这样我们就能理解稍后将要听到的它们的对话:
-
引擎:负责从始至终的编译和执行我们的 JavaScript 程序。
-
编译器:引擎 的朋友之一;处理所有的解析和代码生成的重活儿(见前一节)。
-
作用域:引擎 的另一个朋友;收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。
为了 全面理解 JavaScript 是如何工作的,你需要开始像 引擎(和它的朋友们)那样 思考,问它们问的问题,并像它们一样回答。
反复:
当你看到程序 var a = 2;
时,你很可能认为它是一个语句。但这不是我们的新朋友 引擎 所看到的。事实上,引擎 看到两个不同的语句,一个是 编译器 将在编译期间处理的,一个是 引擎 将在执行期间处理的。
那么,让我们来分析 引擎 和它的朋友们将如何处理程序 var a = 2;
。
编译器 将对这个程序做的第一件事情,是进行词法分析来将它分解为一系列 token,然后这些 token 被解析为一棵树。但是当 编译器 到了代码生成阶段时,它会以一种与我们可能想象的不同的方式来对待这段程序。
一个合理的假设是,编译器 将产生的代码可以用这种假想代码概括:“为一个变量分配内存,将它标记为 a
,然后将值 2
贴在这个变量里”。不幸的是,这不是十分准确。
编译器 将会这样处理:
-
遇到
var a
,编译器 让 作用域 去查看对于这个特定的作用域集合,变量a
是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为a
的新变量。 -
然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值
a = 2
。引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为a
的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方(参见下面的嵌套 作用域 一节)。
如果 引擎 最终找到一个变量,它就将值 2
赋予它。如果没有,引擎 将会举起它的手并喊出一个错误!
总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量(如果先前没有在当前作用域中声明过),第二,当执行时,引擎 在 作用域 中查询这个变量并给它赋值,如果找到的话。
编译器术语:
为了继续更深入地理解,我们需要一点儿更多的编译器术语。
当 引擎 执行 编译器 在第二步为它产生的代码时,它必须查询变量 a
来看它是否已经被声明过了,而且这个查询是咨询 作用域 的。但是 引擎 所实施的查询的类型会影响查询的结果。
在我们这个例子中,引擎 将会对变量 a
实施一个“LHS”查询。另一种类型的查询称为“RHS”。
我打赌你能猜出“L”和“R”是什么意思。这两个术语表示“Left-hand Side(左手边)”和“Right-hand Side(右手边)”
什么的……边?赋值操作的。
换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。
实际上,我们可以表述得更准确一点儿。对于我们的目的来说,一个 RHS 是难以察觉的,因为它简单地查询某个变量的值,而 LHS 查询是试着找到变量容器本身,以便它可以赋值。从这种意义上说,RHS 的含义实质上不是 真正的 “一个赋值的右手边”,更准确地说,它只是意味着“不是左手边”。
当我说:
console.log( a );
这个指向 a
的引用是一个 RHS 引用,因为这里没有东西被赋值给 a
。而是我们在查询 a
并取得它的值,这样这个值可以被传递进 console.log(..)
。
作为对比:
a = 2;
这里指向 a
的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到这个变量,将它作为 = 2
赋值操作的目标。
引擎/作用域对话
function foo(a) { console.log( a ); // 2 } foo( 2 );
让我们将上面的(处理这个代码段的)交互想象为一场对话。这场对话将会有点儿像这样进行:
引擎:嘿 作用域,我有一个 foo 的 RHS 引用。听说过它吗? 作用域;啊,是的,听说过。编译器 刚在一秒钟之前声明了它。它是一个函数。给你。 引擎:太棒了,谢谢!好的,我要执行 foo 了。 引擎:嘿,作用域,我得到了一个 a 的 LHS 引用,听说过它吗? 作用域:啊,是的,听说过。编译器 刚才将它声明为 foo 的一个正式参数了。给你。 引擎:一如既往的给力,作用域。再次感谢你。现在,该把 2 赋值给 a 了。 引擎:嘿,作用域,很抱歉又一次打扰你。我需要 RHS 查询 console。听说过它吗? 作用域:没关系,引擎,这是我一天到晚的工作。是的,我得到 console 了。它是一个内建对象。给你。 引擎:完美。查找 log(..)。好的,很好,它是一个函数。 引擎:嘿,作用域。你能帮我查一下 a 的 RHS 引用吗?我想我记得它,但只是想再次确认一下。 作用域:你是对的,引擎。同一个家伙,没变。给你。 引擎:酷。传递 a 的值,也就是 2,给 log(..)。 ...
嵌套的作用域:
我们说过 作用域 是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的 作用域 需要考虑。
就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎 就会咨询下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(也就是全局作用域)。
考虑这段代码:
function foo(a) { console.log( a + b ); } var b = 2; foo( 2 ); // 4
b
的 RHS 引用不能在函数 foo
的内部被解析,但是可以在它的外围 作用域(这个例子中是全局作用域)中解析。
所以,重返 引擎 和 作用域 的对话,我们会听到:
引擎:“嘿,foo 的 作用域,听说过 b 吗?我得到一个它的 RHS 引用。”
作用域:“没有,从没听说过。问问别人吧。”
引擎:“嘿,foo 外面的 作用域,哦,你是全局 作用域,好吧,酷。听说过 b 吗?我得到一个它的 RHS 引用。”
作用域:“是的,当然有。给你。”
遍历嵌套 作用域 的简单规则:引擎 从当前执行的 作用域 开始,在那里查找变量,如果没有找到,就向上走一级继续查找,如此类推。如果到了最外层的全局作用域,那么查找就会停止,无论它是否找到了变量
建筑的隐喻:
为了将嵌套 作用域 解析的过程可视化,我想让你考虑一下这个高层建筑。
这个建筑物表示我们程序的嵌套 作用域 规则集合。无论你在哪里,建筑的第一层表示你当前执行的 作用域。建筑的顶层表示全局 作用域。
你通过在你当前的楼层中查找来解析 LHS 和 RHS 引用,如果你没有找到它,就坐电梯到上一层楼,在那里寻找,然后再上一层,如此类推。一旦你到了顶层(全局 作用域),你要么找到了你想要的东西,要么没有。但是不管怎样你都不得不停止了。
错误:
为什么我们区别 LHS 和 RHS 那么重要?
因为在变量还没有被声明(在所有被查询的 作用域 中都没找到)的情况下,这两种类型的查询的行为不同。
考虑如下代码:
function foo(a) { console.log( a + b ); b = a; } foo( 2 );
当 b
的 RHS 查询第一次发生时,它是找不到的。它被说成是一个“未声明”的变量,因为它在作用域中找不到。
如果 RHS 查询在嵌套的 作用域 的任何地方都找不到一个值,这会导致 引擎 抛出一个 ReferenceError
。必须要注意的是这个错误的类型是 ReferenceError
。
相比之下,如果 引擎 在进行一个 LHS 查询,但到达了顶层(全局 作用域)都没有找到它,而且如果程序没有运行在“Strict模式”[^note-strictmode]下,那么这个全局 作用域 将会在 全局作用域中 创建一个同名的新变量,并把它交还给 引擎。
“不,之前没有这样的东西,但是我可以帮忙给你创建一个。”
在 ES5 中被加入的“Strict模式”[^note-strictmode],有许多与一般/宽松/懒惰模式不同的行为。其中之一就是不允许自动/隐含的全局变量创建。在这种情况下,将不会有全局 作用域 的变量交回给 LHS 查询,并且类似于 RHS 的情况, 引擎 将抛出一个 ReferenceError
。
现在,如果一个 RHS 查询的变量被找到了,但是你试着去做一些这个值不可能做到的事,比如将一个非函数的值作为函数运行,或者引用 null
或者 undefined
值的属性,那么 引擎 就会抛出一个不同种类的错误,称为 TypeError
。
ReferenceError
是关于 作用域 解析失败的,而 TypeError
暗示着 作用域 解析成功了,但是试图对这个结果进行了一个非法/不可能的动作。