V8 中 JavaScript 是如何解析与执行的

152 阅读6分钟

在 V8 中解析与执行一段 JavaScript 代码的流程大致如下:

本篇文章将会为大家介绍各个阶段具体做了什么。

生成 AST

编译器与解释器如何翻译代码

首先简单介绍一下编译器与解释器如何翻译代码:

AST 是什么

AST(Abstract Syntax Tree,抽象语法树)是一种树状的数据结构,用于表示编程语言的抽象语法结构。AST 是源代码语法结构的抽象表示,不包含具体的细节如空格、注释等。

function add(a, b) {
  return a + b;
}

对应的 AST(使用JavaScript AST Visualiser – Demo applications & examples 转化):

生成 AST 分两个阶段,先分词,后解析。

分词

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。

var a = 1;

其中“var”,“a”,“=”,“1”,“;”都是token。

解析

第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

生成执行上下文

执行上下文概述

在执行过程中,JavaScript 引擎会创建执行上下文(Execution Context)。执行上下文包括变量环境(Variable Environment)、词法环境(Lexical Environment)、this 指向等信息。每次函数调用都会创建一个新的执行上下文,并形成执行上下文栈(Execution Context Stack)。

JavaScript 引擎用栈来维护程序执行期间上下文的状态,以foo函数举例,foo 函数开始执行,foo函数执行上下文会被推入调用栈,执行上下文中有变量环境、词法环境等信息。当 foo 函数执行结束时,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo 函数执行上下文栈区空间全部回收,具体过程你可以参考下图:

变量提升

简介

在执行上下文创建阶段,JavaScript 引擎会对变量和函数进行提升。这意味着在执行代码之前,变量和函数的声明会被提升到执行上下文的顶部。但是,只有声明会被提升,赋值不会提升。

console.log(x); // undefined
var x = 5;

上述代码在执行时相当于:

var x;
console.log(x); // undefined
x = 5;

JavaScript 如何支持块级作用域

ES6 可以通过使用 let 或者 const 关键字来实现块级作用域。

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()
阶段图解解释
编译并创建执行上下文时函数内部通过 var 声明的变量,在编译阶段全都放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。
执行代码时此时在函数的作用域块内部,通过 let 声明的变量才会被存放到词法环境中。在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。(本段中变量均指使用 let 或 const 声明的变量)
当作用域块执行结束之后其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如左图。

foo 函数内变量查找流程如下:

闭包的形成

function foo() {
    var myName = "yuri"
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("Tiffany")
bar.getName()
console.log(bar.getName())

站在内存模型的角度来分析这段代码的执行流程:

  1. 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

总的来说,产生闭包的核心有两步:

第一步是需要预扫描内部函数

第二步是把内部函数引用的外部变量保存到堆中

生成字节码

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。字节码占用的内存远小于机器码。

V8 初期没有字节码,需要消耗大量的内存来存放转换后的机器码。为解决内存占用问题,引入了解释器 Ignition ,它会根据 AST 生成字节码,并解释执行字节码。

执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。到了这里,相信你已经发现了,解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。

在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot) ,比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。这种技术称为即时编译(JIT)

参考资料

《浏览器工作原理与实践》