编译阶段

编译原理

JavaScript 是一门编译语言。与传统的编译语言不同的是,JavaScript 不是提前编译的,编译结果也不能在分布式系统中进行移植。

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为 编译

  1. 分词 / 词法分析
  2. 解析 / 语法分析
  3. 代码生成

分词和词法分析

词法分析(Tokenizing / Lexing)这个过程会将由字符组成的字符串分解成有意义的代码块(对编程语言来说),这些代码块被称为 词法单元(Token)。

🌰 代码示例

const a = 2;

这段程序通常会被分解成为下列词法单元:vara=2;

空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。

分词(Tokenizing)和词法分析(Lexing)之间的主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是 有状态的解析规则,那么这个过程就被称为 词法分析

解析和语法分析

语法分析(Parsing) 这个过程是将词法单元流转换成一个 由元素逐级嵌套所组成 的代表了程序语法结构的树。这个树被称为 抽象语法树(Abstract Syntax Tree,AST 在各大框架及 Babel 中我们都会看到它的身影)。

代码生成

将 AST 转换为可执行代码的过程被称为 代码生成。这个过程与语言、目标平台等息息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组 机器指令:创建一个叫做 a 的变量(包括 分配内存 等),并将一个值存储在变量 a 中。

通过上述三个阶段,浏览器已经可以运行我们得到的 可执行代码,这三个阶段还有一个合称叫 编译阶段。我们把之后对可执行代码的执行称为 运行阶段

编译过程

编译过程中的关键角色:

  • 引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程
  • 编译器:负责语法分析及代码生成等步骤
  • 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

编译过程详解

const a = 2;

通过以上论述可以得知,编译器首先会将这段代码分解成词法单元,然后将词法单元解构成一个树结构(AST),但是当编译器开始进行代码生成时,它对这段代码的处理方式会和预期的情况有所不同。

当我们看到这行代码,用伪代码与别人进行概括时,可能会表述为:为一个变量分配内存,并将其命名为变量 a,然后将值 2 保存到这个变量(内存)中。

然而,这并不完全正确。

事实上编译器会进行如下操作:

  1. 执行流遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
  2. 接下来编译器会为引擎生成运行所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中,是否存在一个叫作 a 的变量,如果是,引擎就会使用这个变量;如果否,引擎就会继续查找该变量。

总结起来就是:

  • 编译器在作用域声明变量(如果没有)

  • 引擎在运行这些代码时查找该变量,如果作用域中有该变量则进行赋值

在上面的第二步中,引擎执行运行时所需的代码时,会通过查找变量 a 来判断它是否已经声明过。查找的过程由作用域进行协助,但是引擎执行怎么查找,会影响最终的查找结果。

还是 var a = 2; 这个例子,引擎会为变量 a 进行 LHS 查询。当然还有一种 RHS 查询。

那么 LHS 和 RHS 查询是什么呢?

这里的 L 代表左侧,R 代表右侧。通俗且不严谨的解释 LHS 和 RHS 的含义就是:当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。

那么描述的更准确的一点,RHS 查询与简单的查找某个变量的值毫无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。

从这个角度说,RHS 并不是真正意义上的"赋值操作的右侧",更准确的说是"非左侧"。所以,我们可以将 RHS 理解成 Retrieve his source value(取到它的源值),这意味着,"得到某某的值"。

那我们来看一段代码深入理解一下 LHS 与 RHS。

function foo(a) {
console.log(a);
}
foo(2);
  • console.log(a) 中,变量 a 的引用是一个 RHS 引用,因为我们是取到 a 的值。并将这个值传递给 console.log(…) 方法
  • 相比之下,例如: a = 2 ,调用 foo(2) 时,隐式的进行了赋值操作。这里对 a 的引用就是 LHS 引用,因为我们实际上不关心当前的值时什么,只要想把 =2 这个赋值操作找到一个目标。

LHS 和 RHS 的含义是 赋值操作的左侧或右侧 并不一定意味着就是 = 赋值操作符的左侧或右侧

赋值操作还有其他几种形式,因此在概念上最好将其理解 赋值操作的目标是谁(LHS) 以及 谁是赋值操作的源头(RHS)。

当然上面的程序并不只有一个 LHS 和 RHS 引用:

function foo(a) {
// 这里隐式的进行了对形参 a 的 LHS 引用。
// 这里对 log() 方法进行了 RHS 引用,询问 console 对象上是否有 log() 方法。
// 对 log(a) 方法内的 a 进行 RHS 引用,取到 a 的值。
console.log(a);
// 2
}
// 此处调用 foo() 方法,需要调用对 foo 的 RHS 引用。意味着"去找foo这个值,并把它给我"
foo(2);

需要注意的是:我们经常会将函数声明 function foo(a) {...} 转化为普通的变量赋值(函数表达式) var foo = function(a) {},这样去理解的话,这个函数是 LHS 查询。但是有一个细微的差别,编译器可以在代码生成的同时处理声明和值的定义,比如引擎执行代码时,并不会有线程专门用来将一个函数值"分配给" foo,因此,将函数声明理解成前面讨论的 LHS 查询和赋值的形式并不合适。

💡 综上所述,作用域是一套 标识符的查询规则(注意这里的用词是规则),JavaScript 编译引擎执行时根据查找的目的进行 LHS 与 RHS 查询。这套查询规则确定标识符在何处(当前作用域、上层作用域或全局作用域)以及如何查找(LHS、RHS)。