重新演绎 You don't know JS<1>: 从JS是一门编译型语言说起

154 阅读7分钟
原文链接: boxueio.com

提起几乎任何一门编程语言都具备的语法特性,应该就是变量了。有了变量,我们就可以把程序执行过程中的值保存起来,稍后再取出来使用。如果没有这个特性,我们几乎很难编写出真正有用的程序。

但是有了变量,站在编译器的角度,也就有了一个复杂的问题:不同类型的变量应该放在哪呢?如何根据代码访问到正确的变量呢?

为了解决这些问题,编程语言中伴随着变量,引入了另外一个概念,叫做作用域,也叫做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会先对代码进行分词,变成这样:vara=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中的作用域是如何界定的呢?