原理解析:JS 代码是如何被浏览器引擎编译、执行的?

936 阅读9分钟

这是我参与11月更文挑战的第11天,活动详情查看:2021最后一次更文挑战

  分析浏览器引擎对 JS 代码的编译情况,并结合日常的 JavaScript 开发经验,重新理解底层的编译解析机制。对其底层原理的理解,将有助于理解前端的跨端应用,以及一套代码生成多种小程序相关框架的底层逻辑.

在开始前请先思考:

  1. JavaScript 代码被执行分为哪几个阶段?
  2. AST 到底是做什么用的?

V8 引擎介绍

  当前百花齐放的编程语言,主要分为编译型语言和解释型语言。

  1. 编译型语言的特点是在代码运行前编译器直接将对应的代码转换成机器码,运行时不需要再重新翻译,直接可以使用编译后的结果。
  2. 解释型语言也是需要将代码转换成机器码,但是和编译型的区别在于运行时需要转换。比较显著的特点是,解释型语言的执行速度要慢于编译型语言,因为解释型语言每次执行都需要把源码转换一次才能执行。

  像JavaC++ 都是编译型语言;而 JavaScript ruby 都是解释性语言,它们整体的执行速度都会略慢于编译型的语言。

  为了提高运行效率,很多浏览器厂商在也在不断努力。目前市面上有很多种 JS 引擎,例如 JavaScriptCorechakraV8 等。而比较现代的 JS 引擎,当数 V8,它引入了 Java 虚拟机和 C++ 编译器的众多技术,和早期的 JS 引擎工作方式已经有了很大的不同。

  V8 是众多浏览器的 JS 引擎中性能表现最好的一个,并且它是 Chrome 的内核,Node.js 也是基于 V8 引擎研发的,V8 引擎很具有代表性。

   V8 引擎执行 JS 代码都要经过哪些阶段

  1. Parse 阶段:V8 引擎负责将 JS 代码转换成 AST(抽象语法树);
  2. Ignition 阶段:解释器将 AST 转换为字节码,解析执行字节码也会为下一个阶段优化编译提供需要的信息;
  3. TurboFan 阶段:编译器利用上个阶段收集的信息,将字节码优化为可以执行的机器码;
  4. Orinoco 阶段:垃圾回收阶段,将程序中不再使用的内存空间进行回收。

  其中,生成 AST、生成字节码、生成机器码是比较重要的三个阶段。

生成 AST

  身为一名前端开发,肯定在日常工作中用过 Eslint Babel 这两个工具,这些工具每个都和 AST 脱不了干系。V8 引擎就是通过编译器(Parse)将源代码解析成 AST 的。

  AST 在实际工作中应用场景也比较多,我们来看看抽象语法树的应用场景,大致有下面几个:

  1. JS 反编译,语法解析;
  2. Babel 编译 ES6 语法;
  3. 代码高亮;
  4. 关键字匹配;
  5. 代码压缩。

  这些场景的实现,都离不开通过将 JS 代码解析成 AST 来实现。生成 AST 分为两个阶段,一是词法分析,二是语法分析,我们来分别看下这两个阶段的情况。

  1. 词法分析:这个阶段会将源代码拆成最小的、不可再分的词法单元,称为 token。比如这行代码 var a =1;通常会被分解成 var 、a、=、1、; 这五个词法单元。另外刚才代码中的空格在 JavaScript 中是直接忽略的。
  2. 语法分析:这个过程是将词法单元转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为抽象语法树。

  简单看一下解析成抽象语法树之后是什么样子的,代码如下。

// 第一段代码
var a = 1;

// 第二段代码
function sum (a,b) {
  return a + b;
}

  将这两段代码,分别转换成 AST 抽象语法树之后返回的 JSON 格式如下。

第一段代码,编译后的结果:

{
  "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"
}

第二段代码,编译出来的结果:

{
  "type": "Program",
  "start": 0,
  "end": 38,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 38,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "sum"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 14,
          "end": 15,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 38,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 23,
            "end": 36,
            "argument": {
              "type": "BinaryExpression",
              "start": 30,
              "end": 35,
              "left": {
                "type": "Identifier",
                "start": 30,
                "end": 31,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 34,
                "end": 35,
                "name": "b"
              }
            }
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}

  从上面编译出的结果可以看到,AST 只是源代码语法结构的一种抽象的表示形式,计算机也不会去直接去识别 JS 代码,转换成抽象语法树也只是识别这一过程中的第一步。

  前面提到了前端领域经常使用一个工具 Babel,比如现在浏览器还不支持 ES6 语法,需要将其转换成 ES5 语法,这个过程就要借助 Babel 来实现。将 ES6 源码解析成 AST,再将 ES6 语法的抽象语法树转成 ES5 的抽象语法树,最后利用它来生成 ES5 的源代码。另外 ESlint 的原理也大致相同,检测流程也是将源码转换成抽象语法树,再利用它来检测代码规范。

  抽象语法树是一个很重要的概念,需要深入了解,才能更好地帮助我们理解所写代码。如果你想自己把代码翻译成 AST,我给你提供一个地址,代码帖进去就可以转换成相应的 ASTAST 在线转换

生成字节码

  将抽象语法树转换为字节码,也就是上面提到的 Ignition 阶段。这个阶段就是将 AST 转换为字节码,但是之前的 V8 版本不会经过这个过程,最早只是通过 AST 直接转换成机器码,而后面几个版本中才对此进行了改进。如果将 AST 直接转换为机器码还是会有一些问题存在的,例如:

  1. 直接转换会带来内存占用过大的问题,因为将抽象语法树全部生成了机器码,而机器码相比字节码占用的内存多了很多;
  2. 某些 JavaScript 使用场景使用解释器更为合适,解析成字节码,有些代码没必要生成机器码,进而尽可能减少了占用内存过大的问题。

  而后,官方在 V8 v5.6 版本中还是将抽象语法树转换成字节码这一过程又加上了,重新加入了字节码的处理过程。再然后,V8 重新引进了 Ignition 解释器,将抽象语法树转换成字节码后,内存占用显著下降了,同时也可以使用 JIT 编译器做进一步的优化。

  其实字节码是介于 AST 和机器码之间的一种代码,需要将其转换成机器码后才能执行,字节码可以理解为是机器码的一种抽象。Ignition 解释器除了可以快速生成没有优化的字节码外,还可以执行部分字节码。

生成机器码

  在 Ignition 解释器处理完之后,如果发现一段代码被重复执行多次的情况,生成的字节码以及分析数据会传给 TurboFan 编译器,它会根据分析数据的情况生成优化好的机器码。再执行这段代码之后,只需要直接执行编译后的机器码,这样性能就会更好。

  这里简单说一下 TurboFan 编译器,它是 JIT 优化的编译器,因为 V8 引擎是多线程的,TurboFan 的编译线程和生成字节码不会在同一个线程上,这样可以和 Ignition 解释器相互配合着使用,不受另一方的影响。

  由 Ignition 解释器收集的分析数据被 TurboFan 编译器使用,主要是通过一种推测优化的技术,生成已经优化的机器码来执行。这个过程我只是通过文字描述,可能你很难理解,你通过一张图来看下整个生成抽象语法树,再到转换成字节码以及机器码的一个过程。

在这里插入图片描述

总结

JavaScript 代码被执行分为哪几个阶段?

  1. Parse 阶段:V8 引擎负责将 JS 代码转换成 AST(抽象语法树);
  2. Ignition 阶段:解释器将 AST 转换为字节码,解析执行字节码也会为下一个阶段优化编译提供需要的信息;
  3. TurboFan 阶段:编译器利用上个阶段收集的信息,将字节码优化为可以执行的机器码;
  4. Orinoco 阶段:垃圾回收阶段,将程序中不再使用的内存空间进行回收。

AST 到底是做什么用的?

  1. JS 反编译,语法解析;
  2. Babel 编译 ES6 语法;
  3. 代码高亮;
  4. 关键字匹配;
  5. 代码压缩。

生成 AST 的阶段

  1. 词法分析:这个阶段会将源代码拆成最小的、不可再分的词法单元,称为 token
  2. 语法分析:这个过程是将词法单元转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为抽象语法树。

  目前市面上比较主流的 JavaScript 引擎编译过程大部分都类似,主要原因可能在于某些地方加入了特定的优化,但是其核心思路和 V8 大体上是一致的

  AST 是比较重要的一个知识点,只有学习了 AST 之后,你才能在自己实现前端工具上面游刃有余。