6

什么是V8?

V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码。

V8 采用混合使用编译器和解释器的技术,称为 JIT(Just In Time)技术。

下面是 V8 执行 JavaScript 代码的流程图:

image-20210520090932069

先了解下相关概念

栈空间(Stack)

这里的栈空间就是调用栈(Call Stack),是用来存储执行上下文的。

在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原始类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个
函数执行结束,那么该函数的执行上下文便会被销毁掉。

为什么使用栈结构来管理函数调用?

我们知道,大部分高级语言都不约而同地采用栈这种结构来管理函数调用,为什么呢?这与函数的特性有关。通常函数有两个主要的特性:

  1. 第一个特点是函数可以被调用,你可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;
  2. 第二个特点是函数具有作用域机制,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为临时变量,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。

我们可以先看下面这段 C 代码:

int getZ() {
    return 4;
}
int add(int x, int y) {
    int z = getZ();
    return x + y + z;
}
int main() {
    int x = 5;
    int y = 6;
    int ret = add(x, y);
}

具体的函数调用示意图如下:

image-20210520131342132

我们可以得出,函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)

因为函数是有作用域机制的,作用域机制通常表现在函数执行时,会在内存中分配函数内部的变量、上下文等数据,在函数执行完成之后,这些内部数据会被销毁掉。

所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。如下图所示:

image-20210520131508506

通过观察函数的生命周期和函数的资源分配情况,我们发现,它们都符合后进先出 (LIFO) 的策略,而栈结构正好满足这种后进先出 (LIFO) 的需求,所以我们选择栈来管理函数调用关系是一种很自然的选择。

栈如何管理函数调用?

当一个函数被执行时,函数的参数、函数内部定义变量都会依次压入到栈中,我们结合实际的代码来分析下这个过程,你可以参考下图:

image-20210520132107789

  • 当执行到函数的第一段代码的时候,变量 x 第一次被赋值,且值为 5,这时 5 会被压入到栈中。
  • 然后,执行第二段代码,变量 y 第一次被赋值,且值为 6,这时 6 会被压入到栈中。
  • 接着,执行到第三段代码,注意这里变量 x 是第二次被赋值,且新的值为 100,那么这时并不是将 100 压入到栈中,而是替换之前压入栈的内容,也就是将栈中的 5 替换成 100。
  • 最后,执行第四段代码,这段代码是 int z = x + y,我们会先计算出来 x+y 的值,然后再将 x+y 的值赋值给 z,由于 z 是第一次被赋值,所以 z 的值也会被压入到栈中。

你会发现,函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。

来看一下复杂一点的场景:

int add(num1, num2) {
    int x = num1;
    int y = num2;
    int ret = x + y;
    return ret;
}
int main() {
    int x = 5;
    int y = 6;
    x = 100;
    int z = add(x, y);
    return z;
}

我们把上段代码中的 x+y 改造成了一个 add 函数,当执行到 int z = add(x,y) 时,当前栈的状态如下所示:

image-20210520132829754

接下来,就要调用 add 函数了,理想状态下,执行 add 函数的过程是下面这样的:

image-20210520132850265

当执行到 add 函数时,会先把参数 num1 和 num2 压栈,接着我们再把变量 x、y、ret 的值依次压栈,不过执行这里,会遇到一个问题,那就是当 add 函数执行完成之后,需要将执行代码的控制权转交给 main 函数,这意味着需要将栈的状态恢复到 main 函数上次执行时的状态,我们把这个过程叫恢复现场

那么应该怎么恢复 main 函数的执行现场呢?

其实方法很简单,只要在寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素,这个指针通常存放在 esp 寄存器中。如果你想往栈中添加一个元素,那么你需要先根据 esp 寄存器找到当前栈顶的位置,然后在栈顶上方添加新元素,新元素添加之后,还需要将新元素的地址更新到 esp 寄存器中。

有了栈顶指针,就很容易恢复 main 函数的执行现场了,当 add 函数执行结束时,只需要将栈顶指针向下移动就可以了,具体你可以参看下图:

add函数即将执行结束的状态

恢复mian函数执行现场

观察上图,将 esp 的指针向下移动到之前 main 函数执行时的地方就可以,不过新的问题又来了,CPU 是怎么知道要移动到这个地址呢?

CPU 的解决方法是增加了另外一个 ebp 寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针,ebp 寄存器中保存的就是当前函数的栈帧指针,如下图所示:

image-20210520134342721

在 main 函数调用 add 函数的时候,main 函数的栈顶指针就变成了 add 函数的栈帧指针,所以需要将 main 函数的栈顶指针保存到 ebp 中,当 add 函数执行结束之后,我需要销毁 add 函数的栈帧,并恢复 main 函数的栈帧,那么只需要取出 main 函数的栈顶指针写到 esp 中即可 (main 函数的栈顶指针是保存在 ebp 中的),这就相当于将栈顶指针移动到 main 函数的区域。

那么现在,我们可以执行 main 函数了吗?

答案依然是“不能”,这主要是因为 main 函数也有它自己的栈帧指针,在执行 main 函数之前,我们还需恢复它的栈帧指针。如何恢复 main 函数的栈帧指针呢?

通常的方法是在 main 函数中调用 add 函数时,CPU 会将当前 main 函数的栈帧指针保存在栈中,如下图所示:

image-20210520134442690

当函数调用结束之后,就需要恢复 main 函数的执行现场了,首先取出 ebp 中的指针,写入 esp 中,然后从栈中取出之前保留的 main 的栈帧地址,将其写入 ebp 中,到了这里 ebp 和 esp 就都恢复了,可以继续执行 main 函数了。

另外在这里,我们还需要补充下栈帧的概念,因为在很多文章中我们会看到这个概念,每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

以上我们详细分析了 C 函数的执行过程,在 JavaScript 中,函数的执行过程也是类似的,如果调用一个新函数,那么 V8 会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,而栈结构的容量是固定的,所有如果重复嵌套执行一个函数,那么就会导致栈会栈溢出。

堆空间(Heap)

好了,我们现在理解了栈是怎么管理函数调用的了,使用栈有非常多的优势:

  1. 栈的结构和非常适合函数调用过程。
  2. 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

虽然操作速度非常快,但是栈也是有缺点的,其中最大的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。

因为栈空间是有限的,这就导致我们在编写程序的时候,经常一不小心就会导致栈溢出,比如函数循环嵌套层次太多,或者在栈上分配的数据过大,都会导致栈溢出,基于栈不方便存放大的数据,因此我们使用了另外一种数据结构用来保存一些大数据,这就是

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,JavaScript 中除了原始类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的。

和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它,为了更好地理解堆,我们看下面这段代码是怎么执行的:

struct Point
{
    int x;
    int y;
};
int main()
{
    int x = 5;
    int y = 6;
    int *z = new int;
    *z = 20;

    Point p;
    p.x = 100;
    p.y = 200;

    Point *pp = new Point();
    pp->y = 400;
    pp->x = 500;
    delete z;
    delete pp;
    return 0;
}

观察上面这段代码,你可以看到代码中有 new int、new Point 这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中,下面我们来看看当 main 函数快执行结束时,堆和栈的状态,具体内容你可以参看下图:

image-20210520134919118

观察上图,我们可以发现,当使用 new 时,我们会在堆中分配一块空间,在堆中分配空间之后,会返回分配后的地址,我们会把该地址保存在栈中,如上图中 z 和 pp 都是地址,它们保存在栈中,指向了在堆中分配的空间。

通常,当堆中的数据不再需要的时候,需要对其进行销毁,在 C 语言中可以使用 free,在 C++ 语言中可以使用 delete 来进行操作。

JavaScript,Java 使用了自动垃圾回收策略,可以实现垃圾自动回收,但是事情总有两面性,垃圾自动回收也会给我们带来一些性能问题。

执行上下文(Execution Context)

简而言之,执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。

执行上下文总共有三种类型:

  • 当JavaScript执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文

创建阶段

在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了两件事情:

  1. LexicalEnvironment(词法环境) 组件被创建。
  2. VariableEnvironment(变量环境) 组件被创建。

因此,执行上下文可以在概念上表示如下:

ExecutionContext = {  
  LexicalEnvironment = { ... },  
  VariableEnvironment = { ... }, 
}

词法环境(Lexical Environment)

官方 ES6 文档将词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。词法环境由环境记录(environment record)和可能为空引用(null)的外部词法环境以及 this binding 组成。

简而言之,词法环境是一个包含标识符变量映射的结构。(这里的标识符表示变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用)。

例如:

var a = 20;
var b = 40;

function foo() {
    console.log('bar');
}

上面的词法环境看起来像这样:

lexicalEnvironment = {
    a: 20,
    b: 40,
    foo: <ref. to foo function>
  }

在词法环境中,有三个组成部分:

  1. 环境记录(environment record)
  2. 对外部环境(Outer Environment)的引用
  3. This binding

环境记录

是存储变量和函数声明的实际位置。

环境记录 同样有两种类型(如下所示):

  • 声明性环境记录 存储变量、函数声明。function code的词法环境包含一个声明性环境记录。
  • 对象环境记录 global code的词法环境包含一个对象环境记录。除了变量和函数声明外,对象环境记录还存储一个global binding object(在浏览器中是 window 对象)。因此,对于每一个绑定对象属性(在浏览器中,它包含浏览器窗口对象提供的属性和方法),在记录中创建一个新条目。
对于函数代码,环境记录该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的长度(数量)。例如,下面函数的 arguments 对象如下所示:
function foo(a, b) {
  var c = a + b;
}
foo(2, 3);
// argument object
Arguments: {0: 2, 1: 3, length: 2},

对外部环境的引用

对外部环境的引用意味着它可以访问其父级词法环境(作用域)。这意味着如果在当前词法环境找不到变量,JavaScript引擎就会在父级词法作用域寻找。

This Binding

在全局执行上下文中,this 的值指向全局对象(在浏览器中,this 的值指向 window 对象)。

在函数执行上下文中,this 的值取决于函数的调用方式。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined(严格模式下)。例如:

const person = {
  name: 'peter',
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear);
  }
}
person.calcAge(); 
// 'this' refers to 'person', because 'calcAge' was called with //'person' object reference
const calculateAge = person.calcAge;
calculateAge();
// 'this' refers to the global window object, because no object reference was given

抽象来看,词法环境看起来像这样的伪代码:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
    }
    outer: <null>,
    this: <global object>
  }
}
FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
    }
    outer: <Global or outer function environment reference>,
    this: <depends on how function is called>
  }
}

详细可以看之前[[JavaScript总结]this绑定全面解析](https://segmentfault.com/a/11...

变量环境(Variable Environment)

它也是一个词法环境,其 EnvironmentRecord 包含了由 VariableStatements 在此执行上下文创建的绑定。

如上所述,变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,LexicalEnvironment 组件和 VariableEnvironment 组件的区别在于前者用于存储函数声明和变量( letconst )绑定,而后者仅用于存储变量( var )绑定。

在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容。

  • lexical environment:词法环境,当获取变量或者 this 值时使用。
  • variable environment:变量环境,当声明变量时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

作用域(scope)

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期

ECMAScript 的作用域有三种:

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
  • 块级作用域可通过letconst声明,声明后的变量再指定块级作用域块外无法被访问。

变量提升(Hoisting)

所谓的变量提升,是指在JavaScript代码执行过程中,JavaScript引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的undefined

看一下下面这段代码:

showName()
console.log(myname)
var myname = 'JavaScript'
function showName() {
    console.log('函数showName被执行');
}

分析下上面的代码:

  • 第1行和第2行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
  • 第3行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
  • 第4行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆(HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。 这样就生成了变量环境对象。

经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。

//执行上下文的变量环境保存了变量提升的内容,也就是myname变量,词法环境保存了showName()。
var myname = undefined
function showName() {
    console.log('函数showName被执行');
}
//可执行代码
showName()
console.log(myname) // undefined
myname = 'JavaScript'

JavaScript引擎开始执行“可执行代码”,按照顺序一行一行地执行。

  • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果;
  • 接下来打印“ myname ”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined;
  • 接下来执行第3行,把 JavaScript 赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为 JavaScript 。

变量提升所带来的问题

1. 变量容易在不被察觉的情况下被覆盖掉

var myname = "JavaScript"
function showName(){
  console.log(myname);
  if(0){
   var myname = "CSS"
  }
  console.log(myname);
}
showName() //undefined

2. 本应销毁的变量没有被销毁

function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo() //7,因为变量提升,for循环结束的时候 i 没有被销毁

所以为了解决这个问题,引用了 块级作用域

作用域链

其实在每个执行上下文的词法(变量)环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer 。

看下面这段代码:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = " CSS "
    bar()
}
var myName = " JavaScript "
foo() // JavaScript

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链

foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

这是因为根据词法作用域,foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。

什么是词法作用域呢?

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。

闭包

JavaScript 中的三个特性:

第一,JavaScript 语言允许在函数内部定义新的函数,代码如下所示:

function foo() {
    function inner() {
    }
    inner()
}

JavaScript 中之所以可以在函数中声明另外一个函数,主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。

第二,可以在内部函数中访问父函数中定义的变量,代码如下所示:

var d = 20
//inner函数的父函数,词法作用域
function foo() {
    var d = 55
    //foo的内部函数
    function inner() {
      return d+2
    }
    inner()
}

由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量。

第三,因为函数是一等公民(First Class Function),所以函数可以作为返回值,我们可以看下面这段代码:

function foo() {
    return function inner(a, b) {
        const c = a + b 
        return c
    }
}
const f = foo()

观察上面这段代码,我们将 inner 函数作为了 foo 函数的返回值,也就是说,当调用 foo 函数时,最终会返回 inner 函数给调用者,比如上面我们将 inner 函数返回给了全局变量 f,接下来就可以在外部像调用 inner 函数一样调用 f 了。

了解了 JavaScript 的这三个特性之后,看看下面这段闭包代码:

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

首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况,你可以参考下图:

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。

你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。

所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

由上可知,在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

V8执行 JavaScript 代码流程

V8 执行 JavaScript 代码,需要经过编译执行两个阶段:

  • 编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段;
  • 执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段。

初始化执行环境

栈空间和堆空间

在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。

全局执行上下文

如果在浏览器中,JavaScript 代码会频繁操作 window(this 默认指向 window 对象)、操作 dom 等内容,如果在 node 中,JavaScript 会频繁使用 global(this 默认指向 global对象)、File API 等内容,这些内容都会在启动过程中准备好,我们把这些内容称之为全局执行上下文

在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。

另外,当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中。

全局作用域

V8 启动时,会创建全局作用域,全局作用域中包括了 this、window 等变量,还有一些全局的 Web API 接口。

全局执行上下文和全局作用域的关系

你可以把作用域看成是一个抽象的概念,比如在 ES6 中,同一个全局执行上下文中,都能存在多个作用域:

var x = 5
{
    let y = 2
    const z = 3
}

这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。

image-20210520154755352

构造事件循环系统

有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行JavaScript 代码了吗?

不,还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。

详细内容单开文章

编译阶段

var name = 'Javascript'
var type = 'global'
function foo(){
var name = 'foo'
console.log(name)
console.log(type)
}
function bar(){
var name = 'bar'
var type = 'function'
foo()
}
bar()

生成抽象语法树(AST)

高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。

从图中可以看出,AST 的结构和代码的结构非常相似,其实你也可以把 AST 看成代码的结构化的表示,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码。

AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

除了 Babel 外,还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

现在你知道了什么是 AST 以及它的一些应用,那接下来我们再来看下 AST 是如何生成的。通常,生成 AST 需要经过两个阶段。

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。你可以参考下图来更好地理解什么 token。

从图中可以看出,通过 var myName = ' JavaScript '简单地定义了一个变量,其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“ JavaScript ”四个都是 token,而且它们代表的属性还不一样。

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

这就是 AST 的生成过程,先分词,再解析。

有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文。

生成执行上下文和作用域

image-20210521202335773

生成字节码

有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。

其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。

但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

那什么是字节码呢?为什么引入字节码就能解决内存占用问题呢?

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

理解了什么是字节码,我们再来对比下高级代码、字节码和机器码,你可以参考下图

执行阶段

生成字节码之后,接下来就要进入执行阶段了。

此时的作用域和执行上下文:

image-20210521205932336

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

参考

图解 Google V8

浏览器工作原理与实践

【译】理解 Javascript 执行上下文和执行栈

Understanding Execution Context and Execution Stack in Javascript

重学前端


人丑就要多读书_
197 声望17 粉丝