前言

JS 引擎只确保 Execution Context 描述的机制,但不确保采用什么手段去实现。

首先,Execution Context 是 ECMAScript 语言规范中的抽象概念,它旨在建立一种易于描述和实现的 ECMAScript 运行机制,JavaScript 是 ECMAScript 的一种实现,而 JavaScript 引擎,例如 V8、SpiderMonkey、Carakan 等,在实现 JavaScript 运行环境时,并不一定会采用固定方式实现 Execution Context,更不会强调哪些类、方法或函数是与 Execution Context 有关的,也许其实现有关的代码到处都是。

但了解 Execution Context 仍然是有意义的,概念可以帮助我们理解现象。

最后,Execution Context 的流行解释有两种,共同点是 JS 执行都要经历两个阶段:创建执行上下文,执行代码;不同点主要在于创建阶段,一种是偏向于 ECMAScript 中的抽象描述——Execution Context 创建词法环境组件与变量环境组件,另一种未找到来源,猜测是个人构思的实现方式,或早期 JS 引擎的实现方式——Execution Context 创建变量对象与作用域链。

二者殊途同归,方式不同,但效果相同,本文主要介绍后者——变量对象与作用域链说,参考自 JavaScript Execution Context – How JS Works Behind The Scenes

Basic

执行 JS 代码总体分为两步:

  1. 创建阶段;
  2. 执行阶段;

执行上下文(Execution Context)是 JS 得以执行的环境,简称 EC,其中包含参数、变量、函数的定义、作用域链以及 this 绑定的对象等,参数、变量的赋值将在执行代码时完成;

其中 EC 有两种:

  1. Global Execution Context,简称 GEC,默认创建的全局执行上下文;
  2. Function Execution Context,简称 FEC,为函数创建的函数执行上下文;

显然函数需要按照一定的顺序执行,FEC 也要按照同样的顺序被访问,执行上下文栈(Execution Context Stack,or Execution Stack,Call Stack)就是保证 EC 按顺序访问的栈结构,GEC 总是位于栈底;

创建阶段

创建阶段会构造执行上下文,而执行上下文以对象的形式存在,即 Execution Context Object,简称 ECO,然后主要做三件事:

  1. 创建变量对象(Variable Object),简称 VO;
  2. 创建作用域链(Scope Chain);
  3. 绑定 this 指针;

上述 VO、Scope Chain、this 以属性的形式存在于 ECO 上;

首先创建全局执行上下文对象,它的 VO 作为全局函数和全局属性的宿主,也称为全局对象(Global Object,GO),GO 在浏览器环境中以window形式出现,Node.js 环境中有相同作用的global

创建变量对象

VO 的创建包含如下内容:

  1. 变量声明,初值为 undefined,已有同名声明则不再赋初值;
  2. 函数声明,初值为对应函数的引用(Reference of Function),值会覆盖同名声明(变量或函数);

重点,基于上述规则,会有如下现象:

  1. 按顺序执行代码,首行调用 func()时,变量func 值为 undefined,抛出异常;
1
2
3
4
5
// func(); throw an error, func is undefined
var func = function () {
console.log("hello v8");
};
func(); // print "hello v8"
  1. 按顺序执行代码,首行调用 func()时,函数func 值为 func 的引用,代码正常运行;
1
2
3
4
5
func(); // print "hello v8"
function func() {
console.log("hello v8");
}
func(); // print "hello v8"

这就是变量提升(Variable Hoisting)与函数提升(Function Hoisting)的区别与根源;

注 1:let 和 const 定义的变量,在其赋值语句出现之前访问该变量,将抛出错误,这是 ECMAScript 规定的——在 let、const 赋值之前,该变量无法访问,这段时间该变量处于暂存死区(Temporal Dead Zone);

注 2:函数参数是通过类数组 arguments 存储的,并不是同变量、函数一同保存在 VO 中;

创建作用域链

JS 中的代码受词法作用域限制,块内部可访问外部变量,反之则不行,Scope Chain 就是对词法作用域的实现,假如以单链表模拟,则每个节点代表执行上下文的 VO,当前执行上下文的 VO 作为头节点,全局执行上下文的 VO 作为尾节点,在解析某个变量时遍历 Scope Chain;

Scope Chain 导致的现象就是闭包(Closure),即当前执行上下文总是可以通过 Scope Chain 访问到父执行上下文中定义的变量;

绑定 this 指针

全局执行上下文的 this 指向 GO,函数执行上下文的 this 指向函数定义时的环境,这句话这样理解:

  1. 全局函数:在全局定义,this 指向 GO;
  2. 对象的方法:在对象中定义,this 指向该对象;

例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var a = 1;

function print() {
console.log(this.a);
}

print(); // 1, print是在全局定义的

var obj = {
a: 2,
print,
};

obj.print(); // 2,print是在obj中定义的

var parentObj = {
a: 3,
obj,
};

parentObj.obj.print(); // 2,print是在obj中定义的;

执行阶段

进入执行阶段,将 GEC 压入 ECS,然后顺序执行代码,执行到赋值语句时就更新 GO 中的变量或函数声明,此时变量才可以取到值。

特别的,执行到函数调用时,为该函数创建 FEC,然后将其压入 ECS,并设为 active,设为 active 的执行上下文最先开始执行,如果函数体包含嵌套的函数调用,则如法炮制——创建 FEC 并入栈,直到没有嵌套的函数调用,执行完栈顶函数后,对应 FEC 出栈,再重复该过程直到所有函数调用完。