关于前端:V8是怎么执行一段JavaScript以及过程中可能涉及到的堆栈执行上下文作用域闭包

53次阅读

共计 13027 个字符,预计需要花费 33 分钟才能阅读完成。

什么是 V8?

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

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

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

先理解下相干概念

栈空间(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);
}

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

咱们能够得出,函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期完结 (先出)

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

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

通过观察函数的生命周期和函数的资源分配状况,咱们发现,它们都合乎 后进先出 (LIFO) 的策略,而栈构造正好满足这种后进先出 (LIFO) 的需要,所以咱们抉择栈来治理函数调用关系是一种很天然的抉择。

栈如何治理函数调用?

当一个函数被执行时,函数的参数、函数外部定义变量都会顺次压入到栈中,咱们结合实际的代码来剖析下这个过程,你能够参考下图:

  • 当执行到函数的第一段代码的时候,变量 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) 时,以后栈的状态如下所示:

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

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

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

其实办法很简略,只有在寄存器中保留一个永远指向以后栈顶的指针,栈顶指针的作用就是通知你应该往哪个地位增加新元素,这个指针通常寄存在 esp 寄存器中。如果你想往栈中增加一个元素,那么你须要先依据 esp 寄存器找到以后栈顶的地位,而后在栈顶上方增加新元素,新元素增加之后,还须要将新元素的地址更新到 esp 寄存器中。

有了栈顶指针,就很容易复原 main 函数的执行现场了,当 add 函数执行完结时,只须要将栈顶指针向下挪动就能够了,具体你能够参看下图:

add 函数行将执行完结的状态

复原 mian 函数执行现场

察看上图,将 esp 的指针向下挪动到之前 main 函数执行时的中央就能够,不过新的问题又来了,CPU 是怎么晓得要挪动到这个地址呢?

CPU 的解决办法是减少了另外一个 ebp 寄存器,用来保留以后函数的起始地位,咱们把一个函数的起始地位也称为 栈帧指针,ebp 寄存器中保留的就是以后函数的栈帧指针,如下图所示:

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

那么当初,咱们能够执行 main 函数了吗?

答案仍然是“不能”,这次要是因为 main 函数也有它本人的栈帧指针,在执行 main 函数之前,咱们还需复原它的栈帧指针。如何复原 main 函数的栈帧指针呢?

通常的办法是在 main 函数中调用 add 函数时,CPU 会将以后 main 函数的栈帧指针保留在栈中,如下图所示:

当函数调用完结之后,就须要复原 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 函数快执行完结时,堆和栈的状态,具体内容你能够参看下图:

察看上图,咱们能够发现,当应用 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
}

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

结构事件循环系统

有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就能够执行 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 就会生成该段代码的执行上下文。

生成执行上下文和作用域

生成字节码

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

其实一开始 V8 并没有字节码,而是间接将 AST 转换为机器码,因为执行机器码的效率是十分高效的,所以这种形式在公布后的一段时间内运行成果是十分好的。

然而随着 Chrome 在手机上的宽泛遍及,特地是运行在 512M 内存的手机上,内存占用问题也裸露进去了,因为 V8 须要耗费大量的内存来寄存转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且摈弃了之前的编译器,最终花了将进四年的工夫,实现了当初的这套架构。

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

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

了解了什么是字节码,咱们再来比照下高级代码、字节码和机器码,你能够参考下图

执行阶段

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

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

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比方一段代码被反复执行屡次,这种就称为热点代码,那么后盾的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,而后当再次执行这段被优化的代码时,只须要执行编译后的机器码就能够了,这样就大大晋升了代码的执行效率。

参考

图解 Google V8

浏览器工作原理与实际

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

Understanding Execution Context and Execution Stack in Javascript

重学前端

正文完
 0