提起几乎任何一门编程语言都具备的语法特性,应该就是变量了。有了变量,我们就可以把程序执行过程中的值保存起来,稍后再取出来使用。如果没有这个特性,我们几乎很难编写出真正有用的程序。
但是有了变量,站在编译器的角度,也就有了一个复杂的问题:不同类型的变量应该放在哪呢?如何根据代码访问到正确的变量呢?
为了解决这些问题,编程语言中伴随着变量,引入了另外一个概念,叫做作用域,也叫做Scope。它为变量的访问,定义了一系列规则。正确理解这些规则,是我们深入了解JavaScript的第一步。
JavaScript是一门编译型语言
无论你之前如何归类JavaScript,现在,你要告诉自己,JavaScript是一门编译型语言。尽管它的编译过程和传统意义的编译型语言有区别,编译的结果也不具备移植性,但JavaScript仍旧是一门编译型语言。这也就意味着,JavaScript代码在执行前,要经历三个主要的阶段,分别是:分词(Tokenizing)、解析(Parse)和生成指令(Code-Generation)。
了解JS引擎的三个角色
在JS代码执行的过程中,有三个主要的角色:一个是JS引擎,这是我们常规意义上编译并执行JS代码的实体。但这个过程并不是JS引擎独立完成的。这里还有两个额外的组件:一个是Compiler,引擎把所有和编译以及生成指令的工作交给它;另一个则是Scope*,引擎需要通过它查询各种变量和符号的访问方式,并执行对应的代码。
可能光这样说有点儿抽象,我们用语句var a = 2
的执行来理解这个过程。
首先,引擎会把var a = 2
交给Compiler进行处理;
其次,Compiler会先对代码进行分词,变成这样:var
,a
,=
,2
;
第三,Compiler基于这样的分词结果,会把赋值语句解析成一个树形结构:
从这个图中可以看到,树的根部表示整个变量定义的语法,它由两部分构成的:一部分是左子树,定义的是变量名;另一部分是右子树,表示初始化变量时的赋值语句。而这个赋值语句自身,仍有一个子树结构,它只有一个子节点,表示复制表达式中的值。
因此,我们就应该知道,一条变量定义语句,会被Compiler处理成两条:
var a;
a = 2;
于是,接下来的故事是这样的:
第四,Compiler会询问Scope,在当前可见的作用域里,是否已经存在变量a
。如果已经存在了,Compiler就会跳过这个声明;否则,Compiler就通知Scope,在当前作用域内登记一个变量a
;
第五,Compiler生成把变量a
赋值数字2的指令。这里,为了避免陷入过于复杂的细节,我们不必关注这些指令具体是什么,只要知道,这些指令可以被JS引擎理解,并完成给a
赋值2这个行为就好了;
接下来,JS引擎只要不断根据Compiler和Scope中的信息,执行对应的指令就好了。但为了更清楚的理解这个过程,我们需要进一步了解两种不同的引擎查询行为。
什么是LHS和RHS
当引擎执行到Compiler分解的a=2
的时候,它要先向Scope查询:嘿,Scope,当前作用域有声明变量a
么?我们管这样的查询叫做Left Hand Side,简称LHS。
既然有Left,就一定会有Right。例如,在a=b
这样的语句里,为了赋值,引擎就会向Scope查询:嘿,帮我查一下变量b
的值是什么。这样的查询,就叫做Right Hand Side,也就是RHS。
所以,形式上来说,LHS查询可以理解为查询等号左边的部分,RHS是在查询等号右边的部分,这是它们各自名字中L和R的来源。
但更准确的说,LHS要查询到的是保存变量的容器,因为我们要修改其中的内容,而所有不属于LHS的查询,都是RHS。之所以这样讲,是因为有些要获取值的查询,并不是发生在等号右边的。来看下面这个例子:
function foo(a) {
console.log(a);
}
foo(2);
在这里,当我们调用foo
的时候,引擎会先向Scope查询foo
的值,这也是一个RHS查询,而后面的(2)
才表示传递参数2并执行foo
的值。
另外一个容易被忽略的查询,是在给函数传参的时候。对foo(2)
来说,引擎要先向Scope查询到函数参数a
,这也是一个LHS查询,因为我们要给参数赋值。而后,在调用log(a)
的时候,参数a
则是一个RHS查询。
看到这里,你应该明白了,为了执行JS程序,引擎每遇到一个符号,都会面临LHS和RHS查询的选择,所有要获取变量容器的查询,属于LHS,而所有LHS之外的查询,都属于RHS。
最后,我们用一个完整的流程图来回顾调用foo
的过程:
和作用域相关的两类错误
理解了LHS和RHS查询之后,我们继续来看基于这两类查询可能发生的错误。不过,在此之前,我们要让之前的例子,变得更丰富一些:
function foo(a) {
console.log(a + b);
}
var b = 2;
foo(2);
理解作用域的嵌套
在上面的代码里,存在着两个作用域。一个是全局作用域,一个是函数foo
提供的作用域,它们是包含关系。无论是LHS还是RHS查询,当引擎无法在当前作用域中找到对应的符号时,就会到它的“外层作用域”中进行查找,直到查到全局作用域为止。
因此,当引擎在foo
中查找b
时(这是一个RHS查询),由于b
并不在foo
的作用域里,引擎就会到全局作用域中查找,因此,最终foo
的执行结果是4。
ReferenceError和TypeError
但是,你可能会想,如果全局作用域也找不到呢?此时,引擎会基于LHS和RHS进行不同的处理。先来看LHS查询,只要引擎没有工作在strict
模式,对于不存在的变量,引擎会默认在全局作用域创建一个:
function foo(a) {
b = 2;
console.log(a + b);
}
console.log(b); // 2
因此,在上面的例子中,当我们在全局作用域中访问b
时,得到的值是2。但如果引擎工作在strict
模式,就会发生ReferenceError
异常:
function foo(a) {
"use strict";
b = 2; // ReferenceError: b is not defined
console.log(a + b);
}
接下来,来看RHS的情况。当变量在所有合法作用域中都不存在的时候,就会发生ReferenceError
异常:
function foo(a) {
console.log(a + b); // ReferenceError: b is not defined
}
或者,当RHS查询成功,但是得到的变量不支持对应操作的时候,则会发生TypeError
:
var b = 2;
b(); // TypeError: b is not a function.
我们要区分这两种不同的错误。
What's next?
以上,就是这一节的内容,我们花了一些时间,站在JS引擎的立场,了解了执行JS代码的时候,作用域发挥的作用。它通过一些规则,决定了在JS中,查询变量的方式。在下一节里,我们将继续讨论和作用域有关的话题:究竟JavaScript中的作用域是如何界定的呢?