前端学堂
学有所用

浏览器JS引擎工作原理

本文主要介绍浏览器引擎基本知识,介绍javascript虚拟机如何解析执行JS脚本,以及期间可以做的优化工作。

webkit

前面浏览器渲染原理中我们介绍了浏览器的渲染进程,是webkit核心blink负责处理的。关于html的渲染解析这里不介绍,webkit中源于两部分:KHTML(主要指渲染部分)和KJS(V8引擎)

V8引擎的执行

整体虚拟机的运行流程如上所示,当然随着不断地迭代,会有些变化,整体流程差不多。v8虚拟机会在JIT解释执行阶段做很多优化工作。解释执行前的工作流程如下:

scanner

scanner 是一个扫描器,用于对纯文本的 JavaScript 代码进行词法分析。它会将代码分析为 tokens。token 在这里是一个非常重要的概念,它是词义单位,是指语法上不能再分割的最小单位,可能是单个字符,也可能是一个字符串。

例如,一段简单的代码如下:

const a = 20

 

上面的代码的 token 集合如下

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

parser

顾名思义,parser 模块我们可以理解为是一个解析器。解析过程是一个语法分析的过程,它会将词法分析结果 tokens 转换为抽象语法树「Abstract Syntax Tree」,同时会验证语法,如果有错误就抛出语法错误。我们可以通过在线网站 esprima 来观察 JavasSript 代码通过词法分析变成 AST 之后的样子。这部分在程序语言进阶之DSL与AST实战解析课程中有介绍。

同样一段代码如下:

const a = 20

被解析成抽象语法树之后,变成

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "value": 20,
            "raw": "20"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

需要注意的是,parser 的解析有两种情况,预解析与全量解析。理解他们有助于你编写性能更优的代码。

预解析 pre-parsing

原则上来说,应该对应用中我们编写的所有代码进行解析。但是实际情况有很大的优化空间。在我们代码里,有大量的代码,虽然声明了函数,但是这部分代码并未被使用,因此如果全部都做 Full-parsing 的话,那么整个解析过程就会做许多无用功。

使用浏览器的调试工具 Coverage 能够清晰的看出来,如下图,表格中的 Usage Visualization 表示的代码使用情况,红色部分表示未被执行过的代码,蓝色部分表示执行过的代码。我们发现,未被使用的代码超过了一半多。这些代码多半是我们在项目中引入的依赖包中声明的函数等。

于是就有了预解析的方案,它在提高代码执行效率上起到了非常关键的作用。它有如下特点

  • 预解析会跳过未被使用的代码
  • 不会生成 AST,会产生不带有变量引用和声明的 scopes 信息
  • 解析速度快
  • 根据规范抛出特定的错误

我们来看这样一段代码

function foo1() {
  console.log('foo1')
}
function foo2() {
  console.log('foo2')
}

foo2();

代码中,声明了两个函数 foo1 和 foo2,但是只有 foo2 被执行了。因此对于 foo1 来说,生成 AST 就变得没有意义。这个时候,foo1 的解析方式就是预解析。但是会生成作用域信息。如图,注意观察作用域引用 Scopes。

全量解析 Full-parsing: Eage

全量解析很好理解,它会解析所有立即执行的代码。这个时候会生成 AST,并且进一步明确更多的信息。

  • 解析被使用的代码
  • 生成 AST
  • 构建具体的 scopes 信息,变量的引用,声明等
  • 抛出所有的语法错误

此时对应的,其实就是执行上下文的创建过程,关于执行上下文我们后续详细分析。需要区分的是,作用域与作用域链的信息是在预解析阶段就已经明确了。分析一下这段代码的解析过程

// 声明时未调用,因此会被认为是不被执行的代码,进行预解析
function foo() {
  console.log('foo')
}

// 声明时未调用,因此会被认为是不被执行的代码,进行预解析
function fn() {}

// 函数立即执行,只进行一次全量解析
(function bar() {

})()

// 执行 foo,那么需要重新对 foo 函数进行全量解析,此时 foo 函数被解析了两次 
foo();

三个函数对应三种不同的情况,函数 foo 在函数声明时,被认为是不被执行的代码,因此进行一次预解析,但是后面会调用执行该方法,因此会再次进行全量解析,也就意味着 foo 函数被解析了两次。

而立即执行函数 bar,在声明时就已经知道会执行,因此只会进行一次全量解析。

函数声明 fn,从头到尾一直未被执行,因此只会进行一次预解析。

那如果我在函数 foo 里面再次声明一个函数呢,那是不是也就意味着,foo 内部的函数也会被跟着解析两次。嵌套层级太深甚至会导致更多次数的解析。因此,减少不必要的嵌套函数,能提高代码的执行效率。这部分可以结合执行上下文 (Execution Context) 和提升 (Hoisting)与事件循环 (Event Loop)这个理解。

注意:V8 引擎会对 parser 阶段的解析结果,缓存 3 天,因此如果我们把不怎么变动的代码打包在一起,如公共代码,把经常变动的业务代码等打包到另外的 js 文件中,能够有效的提高执行效率。

Ignition解释器

Ignition 是 v8 提供的一个解释器。他的作用是负责将抽象语法树 AST 转换为字节码「bytecode」。并且同时收集下一个阶段「编译」所需要的信息。这个过程,我们也可以理解为预编译过程。基于性能的考虑,预编译过程与编译过程有的时候不会区分的那么明显,有的代码在预编译阶段就能直接执行。

TurboFan编译器

TurboFan 是 v8 引擎的编译器模块。它会利用 Ignition 收集到的信息,将字节码转换为汇编代码。这也就是代码被最终执行的阶段。

Ignition + TurboFan 的组合,就是字节码解释器 + JIT 编译器的黄金组合「边解释边执行」。Ignition 收集大量的信息,交给 TurboFan 去优化,多方面条件都满足的情况下,会被优化成机器码,这个过程称为 Optimize,当判断无法优化时就会触发去优化「De-optimize」操作,这些代码逻辑会重新回到 Ignition 中称为字节码。

在这个过程中,有一个建议能够帮助我们避免去优化操作,从而提高代码执行效率。那就是不要总是改变对象类型。例如以下一个例子

function foo(obj) {
  return obj.name
}

由于 JavaScript 的动态性,我们虽然定义了一个函数 foo,但是该函数的参数 obj 并没有明确它的类型,那么这个时候,如果我传入的参数分别为以下几种情况

obj0 = {
  name: 'Alex'
}

obj1 = {
  name: 'tom',
  age: 1
}

obj2 = {
  name: 'Jake',
  age: 1,
  gender: 1
}

对编译器而言,obj0,obj1,obj2 是三种不同的类型,此时 TurboFan 就无法针对这种情况做优化处理,只能执行 De-optimize 操作。这意味着执行效率的降低。因此,定义函数时,严格要求参数格式保持一致,在实践中是非常重要的优化策略,这也是 typescript 的作用之一。

垃圾回收器 Orinoco

在我们执行的 JavaScript 代码中,有大量的垃圾内存需要处理。甚至绝大多数内存占用都是垃圾。因此我们必须有一个机制来管理这些垃圾内存,用于回收利用。这就是垃圾回收器 Orinoco。

垃圾回收器会定期的执行以下任务

  • 标记活动对象,和非活动对象「标记阶段」
  • 回收被非活动对象占用的内存空间「清除阶段」
  • 合并或者整理内存「整理阶段」

v8 的 Compiler Pipeline 并非一开始就是使用的 Ignition + TurboFan 组合。也是在不断的迭代过程中演变而来。例如在他们之前,是 Full-codegen + Crankshaft,并且他们也共存过一段时间。

在官方文档中,提供了一个 PPT,我们可以观察不同版本的演变过程。该 PPT 介绍了为何要使用新的编译组合。

  • 减少了内存占用
  • 减少了启动时间
  • 降低了复杂度

类型推断

上面讲到了档字节码编译器TurboFan无法确定优化代码编译时会触发代码去优化操作,变成低效率执行(每次执行都需要重新编译,而不能复用上次编译的记过)。其中一个重要原因是js弱类型语言导致的,那么编译器一般是怎么做类型推断的呢?见上图。

 

对象访问优化机制

这里既然说到了浏览器js虚拟机编译执行JS代码的过程,顺带说一下v8引擎的对象访问优化的机制,也是为了优化代码的执行效率。

 

参考:

https://trac.webkit.org/wiki

https://36kr.com/p/1641716056065

https://trac.webkit.org/wiki/WebCoreRendering

https://docs.google.com/presentation/d/1chhN90uB8yPaIhx_h2M3lPyxPgdPmkADqSNAoXYQiVE/edit#slide=id.g18d89eb289_1_389

关于对象的访问优化

 

赞(0) 打赏
未经允许不得转载:前端学堂 » 浏览器JS引擎工作原理

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏