编译和执行JS代码

310 阅读7分钟

V8引擎简介——如何编译和执行JS代码

image.png

1.用处

  • chrome浏览器的引擎
  • Nodejs的运行时环境
  • electron的底层引擎

2.什么是V8引擎

常见的浏览器一般由七个模块组成,User Interface(用户界面)、Browser engine(浏览器引擎)、Rendering engine(渲染引擎)、Networking(网络)、JavaScript Interpreter(js解释器)、UI Backend(UI 后端)、Date Persistence(数据持久化存储)

提到Chrome浏览器,一般人会认为使用的Webkit内核,这种说法不完全准确。Chrome发布于2008年,使用的渲染内核是Chromium,它是fork自Webkit,但把Webkit梳理得更有条理可读性更高,效率提升明显。2013年,由于Webkit2和Chromium在沙箱设计上的冲突,谷歌联手Opera自研和发布了Blink内核,逐步脱离了Webkit的影响。所以,可以这么认为:Chromium扩展自Webkit止于Webkit2,其后Chrome切换到了Blink内核。另外,Chrome的JS引擎使用的V8引擎,应该算是最著名和优秀的开源JS引擎,大名鼎鼎的Node.js就是选用V8作为底层架构。

而Blink引擎和V8引擎又是什么关系的?Blink 是 Google Chrome 浏览器的渲染引擎,V8 是Google Chrome 浏览器的 JavaScript 引擎。

  •  是用C++编写的Google开源高性能JS和WebAssembly引擎
  • 简而言之:是一个接收JS代码,编译代码然后执行的C++程序,编译后的代码可以在多种操作系统,多种处理器上运行

主要的工作:

  1. 编译和执行JS代码
  2. 处理调用栈
  3. 内存的分配
  4. 垃圾的回收

3.溯源

大部分JS引擎在编译和执行JS代码都会用到三个重要的组件:

  • 解析器

    • 负责将JS源代码解析成抽象语法树AST(通过词法分析、语法分析形成AST),生成执行上下文
      • JS反编译,语法解析;
      • Babel编译 ES6语法;
      • 代码高亮;
      • 关键字匹配;
      • 代码压缩。
  • 解释器

    • 负责将AST解释成字节码bytecode
    • 也有直接解释执行bytecode的能力
  • 编译器

    • 负责编译出运行更加高效的机器代码

早期的V8引擎是如何编译和执行JS代码的:

  在V8早期5.9版本之前,V8引擎没有解释器,却有两个编译器:

  • JS由解析器解析后,生成AST抽象语法树

  • 然后由编译器(Full-codegen)直接使用AST来编译出机器代码,而不进行任何中间转换

    Full-codegen又称基准编译器,因为它生成的是一个基准的未被优化的机器代码

  • 还有另一个编译器(Crankshaft)——优化编译器:用来优化代码,提升性能

缺陷:

  • 生成的机器码会占用大量的内存
  • 缺少中间的字节码,很多性能优化策略无法实施——V8性能提升缓慢
  • 无法很好的支持和优化JS的新语法特性

4.目前的V8引擎

  • 网页初始化解析执行JS的时间缩短了,网页能够更快的onload
  • 在生成的优化机器代码时,不需要从源码重新编译,而使用字节码,并且当需要回退字节码时,只需要回归到中间层的字节码解释执行就可以了

5.V8引擎中处理JS过程中的一些优化策略

如果一次性全部解析出来所有代码,会占用磁盘空间,也非常消耗内存,并且执行的时间会很长。 所以存在预解析,比如当词法分析阶段读到函数声明时,因为函数不需要立即执行,所以会进行预解析。「即只解析函数声明,不解析函数内部的代码,不会为函数生成AST」。 Pre-Parser 就是用来预解析的。当函数调用的时候才会真正开始解析生成AST。最终会将AST转成字节码。

  1. 如果函数只是声明而未被调用,则不会被解析生成AST

  2. 函数如果只被调用一次,bytecode直接被解释执行

  3. 函数被调用多次,可能会被标记为热点函数,可能会被编译成机器代码——提高代码的执行性能。

    1. 之后执行这个函数时,就直接运行优化后的机器代码 

    2. 随着JS代码的不断执行,会有更多的代码被标记为热点代码,也就会产生更多的机器代码

    3. 此时会有一个问题:回退字节码。

      1. 造成的原因是:如一个sum函数,参数是a,b,多次调用传入整数,且被识别为热点函数,解释器将收集到参数和函数信息编译成优化后的机器码,这里就会假定了sum函数的参数就是整形的

      2. 但是如果某次你传入的不是整数,而是字符串,机器不知道如何处理字符串类型的参数,此时就会deoptimization(回退字节码)

      3. 总结:不要把一个变量类型变来变去,传入的参数的类型也要固定一下

解释器

解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

补充:Ignition->点火器,TurboFan->螺旋增压,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高。

执行上下文

行上下文是对 JavaScript 代码执行环境的一种抽象,每当 JavaScript 运行时,它都是在执行上下文中运行。

执行上下文创建阶段会做三件事:

  • 绑定 this
  • 创建词法环境
  • 创建变量环境

  • 全局执行上下文 —— 当 JS 引擎执行全局代码的时候,会编译全局代码并创建执行上下文,它会做两件事:1、创建一个全局的 window 对象(浏览器环境下),2、将 this 的值设置为该全局对象;全局上下文在整个页面生命周期有效,并且只有一份。

  • 函数执行上下文 —— 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。

  • eval 执行上下文 —— 调用 eval 函数也会创建自己的执行上下文(eval函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因此不推荐使用)

词法作用域

JavaScript采用词法作用域(lexical scoping),也就是静态作用域。

var value = 1;
function foo() {
  console.log(value);
}
function bar() {
  var value = 2;
  foo();
}
 
bar();

执行foo函数,首先从 foo 函数内部查找是否有变量 value ,如果没有
就根据书写的位置,查找上面一层的代码,我们发现value等于1,所以结果会打印 1。
// 例1:
var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f();
}
checkscope();
 
// 例2:
var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f;
}
checkscope()();