V8引擎不完全指南 - 解析器(Parser)

938 阅读5分钟

前言

我们平时日常开发只是单纯地使用 JavaScript 和调用 Web API,对 V8 的理解还停留在表面,并不了解 V8 这个“黑盒”内部是如何工作的。

只有搞清楚这个问题,才能写出性能更好、更优雅的 JavaScript 代码。同时,了解 JavaScript 的执行原理,也能让我们能好理解 Babel 的词法分析和语法分析原理、ESLint 的语法检查机制、React.jsVue 前端框架的底层实现,以后再面对新的技术和框架,也能以不变应万变。

什么是V8?

V8Google 开源的 JavaScript 引擎,被广泛应用于各种 JavaScript 执行环境,比如 Chrome 浏览器、Node.jsElectron 以及 Deno 等。

V8怎么执行JS代码的?

V8 执行 JavaScript 源码时,首先解析器会把源码解析为抽象语法树(Abstract Syntax Tree),解释器(Ignotion)再将 AST 翻译为字节码,一边解释一边执行。

在此过程中,解释器会记特定代码片段的运行次数,如果代码运行次数超过某个阈值,那么该段代码就被标记为热代码(hot code),并将运行信息反馈给优化编译器(TurboFan)。

优化编译器根据反馈信息,优化并编译字节码,最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化机器码执行,不用再次解释,大大提高了代码运行效率。

这种在运行时编译代码的技术也被称为 JIT(即时编译),通过 JIT 可以极大提升 JavaScript 代码的执行性能。

本文我们先来介绍 V8 的解析器。

解析器(Parser)如何把源码转换成 AST?

要让 V8 执行我们编写的源码,就要将源码转换成 V8 能理解的格式。V8 会先把源码解析为一个抽象语法树(AST),这是用来表示源码的树形结构的对象,这个过程称为解析(Parsing),主要由 V8Parser 模块实现。然后, V8 的解释器会把 AST 编译为字节码,一边解释一边执行。

解析和编译过程的性能非常重要,因为 V8 只有等编译完成后才能运行代码(现在我们先关注 V8 中解析过程的实现)。

image.png

整个解析过程可分为两部分。

  • 词法分析:将字符流转换为 tokens,字符流就是我们编写的一行行代码,token 是指语法上不能再分割的最小单位,可能是单个字符,也可能是字符串,图中的 Scanner 就是 V8 的词法分析器。

  • 语法分析:根据语法规则,将 tokens 组成一个有嵌套层级的抽象语法结构树,这个树就是 AST,在此过程中,如果源码不符合语法规范,解析过程就会终止,并抛出语法错误。图中的 ParserPre-Parser 都是 V8 的语法分析器。

词法分析

V8 中,Scanner 负责接收 Unicode 字符流,并将其解析为 tokens,提供给解析器使用。比如 var a = 1; 这行代码,经过词法分析后的 tokens 就是下面这样:

[
    {
        "type": "Keyword",
        "value": "var"
    },
        {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]

可以看到, var a = 1; 这样一行代码包括 5 个 tokens

  • 关键字 var
  • 标识符 name
  • 赋值运算符  =
  • 分割符 ;

语法分析

接下来, V8 的解析器会通过语法分析,根据 tokens 生成 ASTvar a = 1; 这行代码生成的 ASTJSON 结构如下所示:

{
  "type": "Program",
  "start": 0,
  "end": 10,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 10,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

你可以在astexplorer.net/中观察源码通过 Parser 转换后的 AST 的结构。

但是,对于一份 JavaScript 源码,如果所有源码在执行前都要完全经过解析才能执行,那必然会面临以下问题。

  • 代码执行时间变长:一次性解析所有代码,必然会增加代码的运行时间。
  • 消耗更多内存:解析完的 AST,以及根据 AST 编译后的字节码都会存放在内存中,必然会占用更多内存空间。
  • 占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。

所以,现在主流 JavaScript 引擎都实现了延迟解析(Lazy Parsing)。

延迟解析

延迟解析的思想很简单:在解析过程中,对于不是立即执行的函数,只进行预解析(Pre Parser),只有当函数调用时,才对函数进行全量解析。

进行预解析时,只验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST,而实现预解析的,就是 Pre-Parser 解析器。

function foo(a, b) {
    var res = a + b;
    return res;
}

var a = 1;
var c = 2;
foo(1, 2);

由于 Scanner 是按字节流从上往下一行行读取代码的,所以 V8 解析器也是从上往下解析代码。当 V8 解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST

然后 Ignition 解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,先执行 var a = 1;  和 var a = 2; 两个赋值表达式,然后执行函数调用 foo(1, 2) ,这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给 Ignition 解释器编译执行。

结语

以上就是关于 V8 在运行 JavaScript 时,解析器的相关知识,如有不对欢迎各位大佬指正。

参考:《V8是如何执行JavaScript代码的?》- 老蒋