众所周知,前端是一个低门槛,进阶难的一个岗位。而JavaScript又是前端中的重中之重,不管是出于面试还是提升自己,都得学习并掌握JavaScript程序如何在内部执行的。而理解执行上下文和执行栈对于理解其他JavaScript概念(如:提升、作用域和闭包)至关重要。

知识点

  • 什么是执行栈
  • 什么是执行上下文
  • 执行上下文的发展阶段
  • 如何创建执行上下文

什么是执行栈

在学习执行上下文之前,我们先了解一些前置知识:

我们都知道汽车最重要的部分是:引擎(发动机)。JavaScript也是如此,**JavaScript引擎是运行JavaScript代码的发动机**。

执行栈,就是JavaScript引擎用来管理执行上下文的数据结构。代码执行期间的所有执行上下文,都会被存储到执行栈中。栈的特点是后入先出,所以先入栈的执行上下文会在最后才出栈。

执行栈(也叫调用栈),具有LIFO(后入先出)结构,用于存储在代码执行期间创建的所有执行上下文

什么是执行上下文

了解了什么是执行栈之后,接下来我们看一下什么是执行上下文:

JavaScript标准,把一段代码(包括函数)执行所需的所有信息定义为“执行上下文”。

ES2018中,执行上下文被定义为一个抽象的概念,用于描述JavaScript代码在执行时的环境和状态。

简单来说就是:任何代码在JavaScript中运行时,都在执行上下文中运行

执行上下文的类型

执行上下文有三种类型:

  1. 全局执行上下文:默认的执行上下文,任何不在函数内部的代码都位于全局上下文中。它会执行两件事:创建window对象(浏览器下),把this的值指向window对象。一个程序中有且只有一个全局上下文。
  2. 函数执行上下文:每个函数在调用时,都会给该函数创建一个新的执行上下文。函数执行上下文可以有任意个。
  3. eval函数执行上下文:在eval函数内部执行的代码也会获得它自己的执行上下文。

我们通过一个例子来说明一下,假如有以下代码,会生成几个执行上下文呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var name = 'XiaoDai'

function sayHello() {
var info = 'Hello'
return info
}

function sayHi() {
var name = 'XiaoMeng'
return `I'm ${name}`
}

console.log(`${sayHello()} ${name},${sayHi()}`)
console.log(this === window) // true

答案是3个,一个全局执行上下文,2个函数执行上下文。我们用一张图来表示:

执行上下文

那执行栈和执行上下文是怎么互相配合的呢?以上面的代码为例,我们看一下:

  1. JavaScript引擎开始执行第一行代码时,会创建一个全局执行上下文,并把它推入到执行栈中
  2. 当引擎遇到sayHello函数调用的时候,就会创建一个函数执行上下文,并把它推入到执行栈中(此时执行栈中有全局和函数sayHello两个执行上下文)
  3. 此时控制流程交给sayHello,其内代码开始执行,执行结束,该执行上下文被推出执行栈,控制流程交回给全局执行上下文
  4. 当引擎遇到sayHi函数调用的时候,创建了另一个函数执行上下文,并把它推入执行栈中,其内代码开始执行,执行结束后同样被推出执行栈
  5. 然后控制流程交回给栈底的全局执行上下文,代码全部执行完毕后,如果此时关闭浏览器,则全局上下文推出执行栈,否则将一直保留

执行上下文与执行栈

执行上下文的生命周期及发展阶段

执行上下文的生命周期包括两个阶段:创建阶段 -> 执行阶段。

ES3ES5ES2018以及最新的ES2022,每个版本都对执行上下文所包含的内容有所变化,我们逐个梳理。

ES3中的执行上下文

ES3中,执行上下文包含三个部分:

  • scope:作用域(也常被叫做作用域链)
  • variable object:变量对象(用来存储变量的对象)
  • this valuethis

变量对象是与执行上下文相关的数据作用域。存储了在上下文定义的变量和函数声明(一般用VO表示)。

作用域(链):通俗来讲就是数据可访问的范围链。

  1. 全局执行上下文没有外部的作用域,因此定义其作用域链为自己的变量对象。
  2. 当创建函数执行上下文时,会先创建作用域,并把[[scope]]属性(存储了函数所有的外层AO(合集))复制到作用域中,但这并不是完整的作用域链(没有自己的AO),接着创建AO,创建完成后会把AO复制到作用域的顶端,形成完整的作用域链。

全局执行上下文中的变量对象,就是全局对象(一般用GO表示),并会将this指向该全局对象(浏览器中是window对象)。假设我们有以下代码:

1
2
3
4
5
var name = "XiaoDai"

function sayHello() {
return 'Hello'
}

创建阶段:当代码还未执行时,全局对象应该是这样的:

1
2
3
4
5
6
// 伪代码
GO = {
name: undefined,
sayHello: ref <func>, // 函数的引用,函数会在内存中单独开辟一块空间存储,这个引用指向函数空间的内存地址
this: globalObject,
}

之后代码开始逐行执行。这里就解释了为什么var变量存在变量提升的原因了。因为在上下文的创建阶段,已经为var变量赋值为了undefined。所以即使是在变量声明之前调用,也不会报错,返回undefined

执行阶段:当引擎遇到函数调用时,会创建一个函数执行上下文,这个函数执行上下文与全局执行上下文一样包含三个部分。函数执行上下文中的变量对象只有在函数执行上下文中才会被激活,而且只有激活后才可以访问它上面的属性和方法,所以也被称为(活动对象)。需要注意的是:**argument对象也储存在活动对象中**。

1
2
3
4
5
6
7
8
// 伪代码
AO = {
argument: {
length: 0
},
name: undefined,
this: window(由于该函数是直接调用,所以this的指向依然是window
}

之后代码运行到console.log(name)时,从活动对象中所获取到的值还是undefined,所以输出结果也是undefined这就解释了函数体内部变量提升的原因。直到下一行才会为变量name赋值为“XiaoMeng”,之后再打印name就是“XiaoMeng”了。

我们接着修改代码:

1
2
3
4
5
6
7
var name
name()
function name() {
console.log(name)
var name = "XiaoMeng"
return 'Hello'
}
1
2
3
4
5
6
7
var name = 'XiaoDai'
name()
function name() {
console.log(name)
var name = "XiaoMeng"
return 'Hello'
}

猜猜上面的代码会打印什么?答案是:1.Hello 2.name is not a function

其实原因是因为:

  1. 创建阶段:如果变量名称和已经声明的形式参数或函数名相同,则变量声明将不起作用,保留后者。这就是为什么第一个例子会打印Hello
  2. 执行阶段:已经声明的形式参数或函数名会被相同名称的变量赋值覆盖。这就是为什么第二个例子会报错的原因

ES3执行上下文的创建过程总结如下

  • 创建阶段(函数被调用,但还在执行代码之前)
    • 创建作用域:复制函数属性[[scope]]到作用域,在变量对象创建完成后,将其添加到作用域的前端,形成完整的作用域链
    • 创建VO/AO
      • 根据函数的参数,创建并初始化argument对象
      • 扫描函数代码,查找函数声明
        • 对于所有找到的函数声明,将函数名和函数引用存入到VO/AO
        • 如果VO/AO中已有同名函数,进行覆盖
      • 扫描函数内部代码,查找变量声明
      • 对于所有找到的变量声明,存入到VO/AO,并初始化为undefined
      • 如果变量名称和已经声明的形式参数或函数名相同,则变量声明不生效,保留后者
    • 设置this的值
  • 执行阶段
    • 设置变量的值、函数的引用,解释/执行代码

ES5中的执行上下文

在ES5中,对命名方式进行了改进,执行上下文包含三个部分:

  • lexical environment:词法环境组件
  • variable environment:变量环境组件
  • this valuethis

词法环境组件和变量环境组件,结构相同,都由两部分构成:

  • Environment Record(环境记录器):变量和函数声明存储在词法环境中的位置,对于函数代码还额外包含一个参数对象(argument
  • Reference the outer environment(指向外部词法环境的引用):指通过作用域链可以访问父级词法环境

词法环境组件是一个链表结构。可以参考下图进行理解:

词法环境链

这两种环境组件是一种标识符与变量数据的映射,它们都属于词法环境。本质上我们可以这么理解:有两个瓶子要装糖,一种装软糖,一种装硬糖。

  1. 词法环境组件主要用于标记letconstclass等声明
  2. 变量环境组件主要用于标记varfunction等声明

词法环境组件中的环境记录器又分为两种类型:

  • Declarative environment record(声明式环境记录)
  • Object environment record(对象环境记录)

声明式环境记录:用于定义function声明,letconstclassmoduleimport/。声明性环境记录绑定了包含在其作用域内声明定义的标识符集。

1
2
3
4
5
6
import x from '***';
var a=1;
let b=1;
const c=1;
function foo(){};
class Bar{};

对象环境记录:用于定义objectwith语句。每个对象环境记录都与一个对象联系在一起,这个对象被称为绑定对象(binding object)。一个对象环境记录绑定一组字符串标识符名称,直接对应于其绑定对象的属性名称。

1
2
3
4
5
6
7
8
9
10
11
var withObject={
a:1,
foo:function(){
console.log(this.a);
}
}

with(withObject){
a=a+1;
foo(); //2
}
  1. 在全局环境中:环境记录是对象环境记录,并且其不存在有外部环境引用, 指向的值为null
  2. 在函数环境中:环境记录是声明式环境记录,其外部环境引用需要根据词法作用域来判断。
  3. 在模块环境中(仅node):环境记录是声明式环境记录,其外部环境是一个全局环境。

同样我们举例说:

1
2
3
4
5
6
7
8
let a = 1
const b = 2
var c = 3
function foo(d,e) {
var f = 4
return d + e + f
}
c = foo(5, 6)

创建阶段:此时全局执行上下文被创建,this绑定指向window(浏览器下),词法环境组件的记录器中记录了letconstfunction的声明,变量环境组件的记录器中记录var的声明,由于全局执行上下文为顶级上下文,outer指向为null。伪代码类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GlobalExectionContext  = {
thisBinding: <global Object>,
lexicalEnvironment: {
environmentRecord: {
type: 'Object',
a: <uninitialized>,
b: <uninitialized>,
foo: <func>
},
outer: <null>
},
variableEnvironment: {
environmentRecord: {
type: 'Object',
c: <undefined>,
},
outer: <null>
}
}

执行阶段:由于变量提升的原因,首先c被创建,但还未赋值,此时打印出undefined,接着打印b会报错,其实此时b和a也被变量提升了,但是由于letconst声明存在暂时性死区(声明前不能进行访问),所以报错。这就是为什么b报错不是ReferenceError: b is not defined的原因

letconst的小区别:代码运行到let声明语句时,若没有进行赋值操作,则默认值为undefinedconst声明变量必须初始化。

1
2
3
4
5
6
7
8
9
10
11
console.log(c) // undefined
console.log(b) // ReferenceError: can't access lexical declaration 'b' before initialization
console.log(a)
let a = 1
const b = 2
var c = 3
function foo(d,e) {
var f = 4
return d + e + f
}
c = foo(5, 6)

去掉打印后我们接着往下走,执行到最后一行前,全局执行上下文中的变量声明会被赋值。之后调用foo函数,创建一个新的函数执行上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FunctionExectionContext   = {
thisBinding: <Global Object>,
lexicalEnvironment: {
environmentRecord: {
type: 'Declarative',
arguments: {
0: 5,
1: 6,
length: 2
}
},
outer: <GlobalLexicalEnvironment>
},
variableEnvironment: {
environmentRecord: {
type: 'Declarative',
f: <undefined>,
},
outer: <GlobalVariableEnvironment>
}
}

接着进入函数执行上下文的执行阶段:变量f被赋值为4,此时函数执行上下文中的变量环境组件下的记录器的f记录被更新为4。

ES2018中的执行上下文

ES2018中,this值被归入到词法环境中,同时增加了一些内容

  1. 正常情况下会包含如下四个部分
  • lexical environment:词法环境组件(当获取变量或者this时使用)
  • variable environment:变量环境组件(当声明变量时使用)
  • code evaluation state:执行、挂起和恢复与此执行上下文相关的代码计算所需的任何状态
  • Realm:域记录,包含一组完整的内置对象,而且是复制关系。
  1. 特定情况下又会包含以下三个部分
  • Function:执行的任务是函数时,表示正在被执行的函数。否则为null
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。否则为null
    • Generator:仅生成器上下文有这个属性,表示当前生成器

还是以上面的例子为例:

1
2
3
4
5
6
7
8
let a = 1
const b = 2
var c = 3
function foo(d,e) {
var f = 4
return d + e + f
}
c = foo(5, 6)

全局执行上下文的伪代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GlobalExectionContext  = {
lexicalEnvironment: {
environmentRecord: {
type: 'Object',
a: <uninitialized>,
b: <uninitialized>,
foo: <func>
},
outer: <null>,
thisBinding: <global Object>,
},
variableEnvironment: {
environmentRecord: {
type: 'Object',
c: <undefined>,
},
outer: <null>,
thisBinding: <global Object>,
}
}

关于Realm

ECMA262中的原文描述是:Before it is evaluated, all ECMAScript code must be associated with a realm. Conceptually, a realm consists of a set of intrinsic objects, an ECMAScript global environment, all of the ECMAScript code that is loaded within the scope of that global environment, and other associated state and resources.

关键字:

  1. a set of intrinsic objects(一组内置对象)
  2. global environment(一个全局环境)
  3. code(在上面这个全局环境中加载的所有代码)
  4. state and resources(状态和资源)

**a set of intrinsic objects(一组内置对象):包含了所有js基本内置对象以及宿主环境中的的内置对象**,比如:Object,Array,String,Number,Date,Error,Symbol等,来看一段代码:

1
Array.prototype.__proto__ === Object.prototype // true

===符号来对比两个object,是只有当两个对象都指向同一引用时,才会为true

1
2
3
4
5
const a = {}
const b = {}
const c = a
console.log(a === b) // false
console.log(a === c) // true

比如上面的例子:ab都是空对象,但是他们指向不同的引用地址,所以他俩不相等。c = a是因为把c的引用地址指向a的引用地址,他俩指向的是同一个引用地址,所以他俩相等。

global environment(一个全局环境):比如在当前页面中,全局环境就是window,但是需要注意的是,在不同全局环境中的Realms是不同的,可以看作在创建环境前,会新new一个Realms, 而里面所有的内置对象也会是全新的。

比如在当前页面中创建iframe,而对iframe中创建的对象和当前页面中创建的对象用intanceof比较当前页面中的Object,得到的结果是只有当前页面中的对象是true,而在iframe中创建的对象是false,即虽然两个对象的原型都是Object,但是这两个Object是创建于不同的域当中,所以使用instanceof检测的结果也不一致。

1
2
3
4
5
6
7
const iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"
var b1 = iframe.contentWindow.b; // 这是在iframe中创建的对象,也就是在不同Realms中的对象
var b2 = {};
console.log(typeof b1, typeof b2); //object,object
console.log(b1 instanceof Object, b2 instanceof Object); //false true iframe中创建的对象与当前的内置对象的原型链是不同的。

code(在上面这个全局环境中加载的所有代码):这很好理解,就是在环境内的代码,用上面的代码来解释就是:

  • 当前页面:上面代码3中所有的代码
  • iframe页面:var b = {};

state and resources(状态和资源):这里原文没有过多的解释。小呆查询ECMA262原文,猜测可能跟[[LoadedModules]][[HostDefined]]两个字段相关。

ES2022中的执行上下文

ES2022中,执行上下文在ES2018的基础上新增了一个私有环境,其他与ES2018中一致。

  • Private environment:私有环境(仅包含class生成的私有变量,如无则为null

总结

网上关于JavaScript执行上下文的文章很多,但是每篇文章可能只讲了一个版本,这对于面试过程中,和考官就存在版本差,如果没有全面的了解不同版本的差异,兴许就会踩坑。其实从ES3一直到ES2022JavaScript的执行上下文一直在不断的细化和补充,我们从执行上下文这一个点也能看出JavaScript的发展是非常快的。

理解好执行上下文的相关知识,还能从根上解决以下几个问题:

  • this的指向
  • 变量提升、函数提升、letconst到底存不存在提升
  • 为什么函数内部能访问到外部的变量(作用域链)
  • 闭包

其实本来打算把闭包、作用域链和this在不同场景的指向都在这篇文章中详细展开来写的,但是考虑到文章太长,一时间消化所有知识点不太容易,所以还是决定单独写几篇文章进行梳理。

引用

本文内容参考了以下文章及文档,感谢!感兴趣的同学可以进行阅读!

关于 Realms 理解 ES2018 中的 Realms——作者:nathan96
关于 Private environment:ECMAScript2022 官方文档
关于 Realms:ECMAScript2018 官方文档
关于环境记录器:ECMAScript2015 官方文档