视野前端(二)V8引擎是如何工作的

时间:2022-07-22
本文章向大家介绍视野前端(二)V8引擎是如何工作的,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

许多同学在阅读了基础进阶系列文章之后,对JS代码的执行顺序理解得更清晰了。可也有不少好学的大佬在此基础上进一步思考,JS引擎到底是如何工作的?什么时候解析?什么时候执行?特别是在其他地方阅读了不少各种说法的文章之后,疑惑更重了。

这里就以V8引擎为例,跟大家聊一聊,JS引擎是如何工作的。

JS引擎是一个应用程序,它是浏览器引擎的一部分。每个浏览器的JS引擎都不一样。例如chrome的V8,firefox的SpiderMonkey,Safari的Nitro等等。

所有的JS引擎原则上都会按照ECMAScript标准来实现。因此大家的实现方式可能有所差异,解析原理也不尽相同,但大体表现基本上能保持一致。想要了解JS引擎的工作思路,了解V8就足够了。

Chrome(还有Nodejs)的JS引擎是V8,他的内部有许多小的子模块组成。这里我们只需要了解其中最常用的四个模块即可。

1.parser

顾名思义。这个模块的作用是将我们自己编写的JS源码,转换为抽象语法树(Abstract Syntax Tree)。在许多其他文章里,提到的词法语法分析过程,就是 parser 来完成。

我们可以通过在线网站 https://esprima.org/demo/parse.html# 来观察我们的代码通过词法分析变成AST之后大概会是神马样子。

从该工具中,我们还发现一个在介绍词法分析过程的文章里经常提到的一个东西: Token

token: 词义单位,是指语法上不能再分割的最小单位,可能是单个字符,也可能是一个字符串。

工具中使用如下的方式来表示多个tokens

[
  {
    "type": "Keyword",
    "value": "var"
  },
  {
    "type": "Identifier",
    "value": "a"
  },
  {
    "type": "Punctuator",
    "value": "="
  },
  {
    "type": "Numeric",
    "value": "10"
  },
  {
    "type": "Punctuator",
    "value": ","
  },
  {
    "type": "Identifier",
    "value": "b"
  },
  {
    "type": "Punctuator",
    "value": "="
  },
  {
    "type": "Numeric",
    "value": "20"
  }
]

那么,parser模块的工作过程,就比较明了了。大致如下:

此图仅为大致过程,例如官方文档中提到的,tokens的过程具体是由一个名为scanner的扫描工具来完成。

我们知道,声明多个连续的变量时,可以只使用一个关键字,如下:

var a = 10, b = 20

这种方式比多个变量各自声明性能上会更好一点,为什么? 利用工具,观察一下两种方式下tokens和AST的不同,就能马上明白了。

那么问题来了,在这个过程中,执行上下文创建了没有?

其实还没有,我们的代码在这个阶段,还没有正式进入运行。

所以留一个简单的问题,如下的代码,直接执行,在这个阶段会直接报错吗?

如果有兴趣,在评论里留下你的答案与分析。

var a = b;

1.Ignition

在v8文档中可以得知,Ignition是V8提供的一个解释器。他的作用是负责将抽象语法树AST转换为字节码。并同时收集下一个阶段(编译)所需要的信息。这个过程,我们也可以理解为预编译过程。

在之前我对变量对象的介绍中,曾经用下面的方式表达执行上下文的生命周期。这里预编译过程,其实就是执行上下文的第一个阶段。如图所示:

因为基于性能优化的考虑,预编译过程与真正的编译有的时候不会区分的那么明确,有的代码在预编译阶段就能直接执行。

1.TurboFan

V8引擎的编译器模块。利用Ignition收集到的信息,将字节码转换为汇编代码。 这也是我们之前提到过的可执行代码的执行阶段。

当然,到这里,如果不是对V8特别感兴趣的话,就不必在继续深究具体的细节了。基本上JS代码的执行过程都相对清晰。

官方文档中,我们可以查阅一个讲述V8引擎优化过程[1]的一个PPT,可以发现,在不同的版本中,解释器与编译器的交互过程每个版本都在变化。

这里截取了一些图展示编译过程的演变,PPT里面还有很多更详细的介绍,如果感兴趣的同学可以阅读PPT做更深入的了解。

为了达到更好的性能,执行过程并非严格按照先由解释器解析,然后交给编译器编译的定式执行。JS作为解释型的动态语言,在整个解析编译的过程中,就有许多优化的空间。例如我们常常听到的JIT模式。

我们自己也能够猜到一些优化的点:

例如,如果一个函数不被调用,我们可以不用去编译它。

一个函数被调用很多次,那么我们可以想办法给他标记上,只需要编译一次等等。

1.Orinoco

垃圾回收模块。

Orinoco也是使用我们熟知的标记清除法来进行垃圾回收。

当执行上下文创建时,变量进入该环境,我们就可以对该变量对应的内存进行标记。如果执行上下文执行完毕,这个时候,就可以将所有进入该环境的变量标记为可清除状态。我们通俗的说法就是,当一份内存失去了引用,那么它就会被垃圾回收工具回收。

不过还有两个需要注意的地方。

一个是全局上下文。在程序结束之前,全局上下文始终存在。通常来说,JS程序运行期间,全局上下文不会有执行结束的时间节点。因此定义在全局上下文的状态永远都不会被标记。除非我们手动将变量设置为null,它对应的内存都不会被回收。

另外一个是闭包。因为闭包的特性是能够始终保持内存的引用。因此当我们希望利用闭包的特性达到某些目的时,即使它对应的执行上下文已经执行完毕了,我们也会想办法让内存的引用始终保持。

References

[1] V8引擎优化过程: https://docs.google.com/presentation/d/1chhN90uB8yPaIhx_h2M3lPyxPgdPmkADqSNAoXYQiVE/edit#slide=id.g1357e6d1a4_0_58