JS 编译原理

262 阅读7分钟

前端开发与编译

  • 将ES6 代码编译成ES5 代码
  • ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题
  • 将SCSS、LESS 代码转换成浏览器支持的CSS 代码
  • 通过uglify.js、uglifycss 等工具压缩代码
  • 将TS 代码转换成JS 代码
  • Vue 模板语法转换成render 函数,JSX 语法转换成JS代码

社区的工具如babel、*-loader 已经完成了上面的工作。

基本概念

  • 编译器(Compiler)
  • 解释器(Interpreter)
  • 抽象语法树(AST)
  • 字节码(Bytecode)
  • 即时编译器(JIT)

编译器和解释器

按语言的执行流程,可以把语言划分为编译型语言和解释型语言。

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。

由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

image-20230219232041295

V8 执行JS 代码

image-20230219232231846

  • 解释器 Ignition => 点火器
  • 编译器 TurboFan => 涡轮增压

生成AST 和执行上下文

Tokenizing/Lexing

【分词/词法分析】

将一行行的源码拆解成一个个 词法单元(token)。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。

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

Parsing

【解析/语法分析】

将词法单元流(数组)转换成抽象语法树(Abstract Syntax Tree AST)。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文。

var a = 2的抽象语法树中可能会有一个叫做VariableDeclaration 的顶级节点,接下来是一个叫做Identifier(它的值是a)的子节点,以及一个叫做AssignmentExpression 的子节点。AssignmentExpression 节点有一个叫做Numeric Literal(它的值是2)的子节点。

生成字节码和代码执行

有了 AST 和执行上下文后,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。

一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。=> code generate

在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

【字节码配合解释器和编译器 => 即时编译(JIT)】

具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

对于 JavaScript 工作引擎,除了 V8 使用了“字节码 +JIT”技术之外,苹果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了该技术。

image-20230220000834061

参与者

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

var a = 2,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同:

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

LHS 查询,试图找到变量的容器本身,从而可以对其赋值 => 赋值操作的目标是谁

RHS 查询,查找某个变量的值 => 谁是赋值操作的源头

 function foo(a) {
     var b = a
     return a + b
 }
 var c = foo(2)
 ​
 // LHS 查询(3处):c = ..、a = 2 (隐式变量分配)、b = ..
 // RHS 查询(4处):foo(2..、= aa ..、..b

如果RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError 异常。如果找到了一个变量,但是对这个变量的值进行不合理的操作(对一个非函数类型的值进行函数调用,或引用null/undefined 类型的值中的属性),引擎会抛出TypeError。

当引擎执行LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量:

  • 非严格模式下全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎
  • 严格模式中LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS 查询失败时类似的ReferenceError 异常

esprima.js

  • Punctuator、Identifier
  • Body、ExpressionStatement、Expression

【参考资料】 《极客时间:浏览器工作原理与实践》