V8是怎么执行一段JavaScript?以及过程当中可能涉及到的堆栈,执行上下文,做用域,闭包

什么是V8?

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

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

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

image-20210520090932069

先了解下相关概念

栈空间(Stack)

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

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

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

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

  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

重学前端

相关文章
相关标签/搜索