理解JavaScript执行上下文
众所周知,前端是一个低门槛,进阶难的一个岗位。而JavaScript又是前端中的重中之重,不管是出于面试还是提升自己,都得学习并掌握JavaScript程序如何在内部执行的。而理解执行上下文和执行栈对于理解其他JavaScript概念(如:提升、作用域和闭包)至关重要。
知识点
- 什么是执行栈
- 什么是执行上下文
- 执行上下文的发展阶段
- 如何创建执行上下文
什么是执行栈
在学习执行上下文之前,我们先了解一些前置知识:
我们都知道汽车最重要的部分是:引擎(发动机)。JavaScript也是如此,**JavaScript引擎是运行JavaScript代码的发动机**。
而执行栈,就是JavaScript引擎用来管理执行上下文的数据结构。代码执行期间的所有执行上下文,都会被存储到执行栈中。栈的特点是后入先出,所以先入栈的执行上下文会在最后才出栈。
执行栈(也叫调用栈),具有
LIFO(后入先出)结构,用于存储在代码执行期间创建的所有执行上下文
什么是执行上下文
了解了什么是执行栈之后,接下来我们看一下什么是执行上下文:
JavaScript标准,把一段代码(包括函数)执行所需的所有信息定义为“执行上下文”。在
ES2018中,执行上下文被定义为一个抽象的概念,用于描述JavaScript代码在执行时的环境和状态。
简单来说就是:任何代码在JavaScript中运行时,都在执行上下文中运行。
执行上下文的类型
执行上下文有三种类型:
- 全局执行上下文:默认的执行上下文,任何不在函数内部的代码都位于全局上下文中。它会执行两件事:创建
window对象(浏览器下),把this的值指向window对象。一个程序中有且只有一个全局上下文。 - 函数执行上下文:每个函数在调用时,都会给该函数创建一个新的执行上下文。函数执行上下文可以有任意个。
eval函数执行上下文:在eval函数内部执行的代码也会获得它自己的执行上下文。
我们通过一个例子来说明一下,假如有以下代码,会生成几个执行上下文呢?
1 | |
答案是3个,一个全局执行上下文,2个函数执行上下文。我们用一张图来表示:

那执行栈和执行上下文是怎么互相配合的呢?以上面的代码为例,我们看一下:
- 当
JavaScript引擎开始执行第一行代码时,会创建一个全局执行上下文,并把它推入到执行栈中 - 当引擎遇到
sayHello函数调用的时候,就会创建一个函数执行上下文,并把它推入到执行栈中(此时执行栈中有全局和函数sayHello两个执行上下文) - 此时控制流程交给
sayHello,其内代码开始执行,执行结束,该执行上下文被推出执行栈,控制流程交回给全局执行上下文 - 当引擎遇到
sayHi函数调用的时候,创建了另一个函数执行上下文,并把它推入执行栈中,其内代码开始执行,执行结束后同样被推出执行栈 - 然后控制流程交回给栈底的全局执行上下文,代码全部执行完毕后,如果此时关闭浏览器,则全局上下文推出执行栈,否则将一直保留

执行上下文的生命周期及发展阶段
执行上下文的生命周期包括两个阶段:创建阶段 -> 执行阶段。
从ES3、ES5到ES2018以及最新的ES2022,每个版本都对执行上下文所包含的内容有所变化,我们逐个梳理。
ES3中的执行上下文
在ES3中,执行上下文包含三个部分:
scope:作用域(也常被叫做作用域链)variable object:变量对象(用来存储变量的对象)this value:this值
变量对象是与执行上下文相关的数据作用域。存储了在上下文定义的变量和函数声明(一般用VO表示)。
作用域(链):通俗来讲就是数据可访问的范围链。
- 全局执行上下文没有外部的作用域,因此定义其作用域链为自己的变量对象。
- 当创建函数执行上下文时,会先创建作用域,并把
[[scope]]属性(存储了函数所有的外层AO(合集))复制到作用域中,但这并不是完整的作用域链(没有自己的AO),接着创建AO,创建完成后会把AO复制到作用域的顶端,形成完整的作用域链。
全局执行上下文中的变量对象,就是全局对象(一般用GO表示),并会将this指向该全局对象(浏览器中是window对象)。假设我们有以下代码:
1 | |
创建阶段:当代码还未执行时,全局对象应该是这样的:
1 | |
之后代码开始逐行执行。这里就解释了为什么var变量存在变量提升的原因了。因为在上下文的创建阶段,已经为var变量赋值为了undefined。所以即使是在变量声明之前调用,也不会报错,返回undefined。
执行阶段:当引擎遇到函数调用时,会创建一个函数执行上下文,这个函数执行上下文与全局执行上下文一样包含三个部分。函数执行上下文中的变量对象只有在函数执行上下文中才会被激活,而且只有激活后才可以访问它上面的属性和方法,所以也被称为(活动对象)。需要注意的是:**argument对象也储存在活动对象中**。
1 | |
之后代码运行到console.log(name)时,从活动对象中所获取到的值还是undefined,所以输出结果也是undefined。这就解释了函数体内部变量提升的原因。直到下一行才会为变量name赋值为“XiaoMeng”,之后再打印name就是“XiaoMeng”了。
我们接着修改代码:
1 | |
1 | |
猜猜上面的代码会打印什么?答案是:1.Hello 2.name is not a function
其实原因是因为:
- 创建阶段:如果变量名称和已经声明的形式参数或函数名相同,则变量声明将不起作用,保留后者。这就是为什么第一个例子会打印
Hello - 执行阶段:已经声明的形式参数或函数名会被相同名称的变量赋值覆盖。这就是为什么第二个例子会报错的原因
ES3执行上下文的创建过程总结如下:
- 创建阶段(函数被调用,但还在执行代码之前)
- 创建作用域:复制函数属性
[[scope]]到作用域,在变量对象创建完成后,将其添加到作用域的前端,形成完整的作用域链 - 创建
VO/AO:- 根据函数的参数,创建并初始化
argument对象 - 扫描函数代码,查找函数声明
- 对于所有找到的函数声明,将函数名和函数引用存入到
VO/AO - 如果
VO/AO中已有同名函数,进行覆盖
- 对于所有找到的函数声明,将函数名和函数引用存入到
- 扫描函数内部代码,查找变量声明
- 对于所有找到的变量声明,存入到
VO/AO,并初始化为undefined - 如果变量名称和已经声明的形式参数或函数名相同,则变量声明不生效,保留后者
- 根据函数的参数,创建并初始化
- 设置
this的值
- 创建作用域:复制函数属性
- 执行阶段
- 设置变量的值、函数的引用,解释/执行代码
ES5中的执行上下文
在ES5中,对命名方式进行了改进,执行上下文包含三个部分:
lexical environment:词法环境组件variable environment:变量环境组件this value:this值
词法环境组件和变量环境组件,结构相同,都由两部分构成:
Environment Record(环境记录器):变量和函数声明存储在词法环境中的位置,对于函数代码还额外包含一个参数对象(argument)Reference the outer environment(指向外部词法环境的引用):指通过作用域链可以访问父级词法环境
词法环境组件是一个链表结构。可以参考下图进行理解:

这两种环境组件是一种标识符与变量数据的映射,它们都属于词法环境。本质上我们可以这么理解:有两个瓶子要装糖,一种装软糖,一种装硬糖。
- 词法环境组件主要用于标记
let、const、class等声明 - 变量环境组件主要用于标记
var、function等声明
词法环境组件中的环境记录器又分为两种类型:
Declarative environment record(声明式环境记录)Object environment record(对象环境记录)
声明式环境记录:用于定义function声明,let、const、class、module、import、/。声明性环境记录绑定了包含在其作用域内声明定义的标识符集。
1 | |
对象环境记录:用于定义object、with语句。每个对象环境记录都与一个对象联系在一起,这个对象被称为绑定对象(binding object)。一个对象环境记录绑定一组字符串标识符名称,直接对应于其绑定对象的属性名称。
1 | |
- 在全局环境中:环境记录是对象环境记录,并且其不存在有外部环境引用, 指向的值为
null。 - 在函数环境中:环境记录是声明式环境记录,其外部环境引用需要根据词法作用域来判断。
- 在模块环境中(仅
node):环境记录是声明式环境记录,其外部环境是一个全局环境。
同样我们举例说:
1 | |
创建阶段:此时全局执行上下文被创建,this绑定指向window(浏览器下),词法环境组件的记录器中记录了let、const、function的声明,变量环境组件的记录器中记录var的声明,由于全局执行上下文为顶级上下文,outer指向为null。伪代码类似于:
1 | |
执行阶段:由于变量提升的原因,首先c被创建,但还未赋值,此时打印出undefined,接着打印b会报错,其实此时b和a也被变量提升了,但是由于let、const声明存在暂时性死区(声明前不能进行访问),所以报错。这就是为什么b报错不是ReferenceError: b is not defined的原因。
let和const的小区别:代码运行到let声明语句时,若没有进行赋值操作,则默认值为undefined,const声明变量必须初始化。
1 | |
去掉打印后我们接着往下走,执行到最后一行前,全局执行上下文中的变量声明会被赋值。之后调用foo函数,创建一个新的函数执行上下文:
1 | |
接着进入函数执行上下文的执行阶段:变量f被赋值为4,此时函数执行上下文中的变量环境组件下的记录器的f记录被更新为4。
ES2018中的执行上下文
在ES2018中,this值被归入到词法环境中,同时增加了一些内容
- 正常情况下会包含如下四个部分
- lexical environment:词法环境组件(当获取变量或者this时使用)
- variable environment:变量环境组件(当声明变量时使用)
- code evaluation state:执行、挂起和恢复与此执行上下文相关的代码计算所需的任何状态
- Realm:域记录,包含一组完整的内置对象,而且是复制关系。
- 特定情况下又会包含以下三个部分
- Function:执行的任务是函数时,表示正在被执行的函数。否则为null
- ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。否则为null
- Generator:仅生成器上下文有这个属性,表示当前生成器
还是以上面的例子为例:
1 | |
全局执行上下文的伪代码如下,
1 | |
关于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.
关键字:
a set of intrinsic objects(一组内置对象)global environment(一个全局环境)code(在上面这个全局环境中加载的所有代码)state and resources(状态和资源)
**a set of intrinsic objects(一组内置对象):包含了所有js基本内置对象以及宿主环境中的的内置对象**,比如:Object,Array,String,Number,Date,Error,Symbol等,来看一段代码:
1 | |
用===符号来对比两个object,是只有当两个对象都指向同一引用时,才会为true
1 | |
比如上面的例子:a和b都是空对象,但是他们指向不同的引用地址,所以他俩不相等。c = a是因为把c的引用地址指向a的引用地址,他俩指向的是同一个引用地址,所以他俩相等。
global environment(一个全局环境):比如在当前页面中,全局环境就是window,但是需要注意的是,在不同全局环境中的Realms是不同的,可以看作在创建环境前,会新new一个Realms, 而里面所有的内置对象也会是全新的。
比如在当前页面中创建iframe,而对iframe中创建的对象和当前页面中创建的对象用intanceof比较当前页面中的Object,得到的结果是只有当前页面中的对象是true,而在iframe中创建的对象是false,即虽然两个对象的原型都是Object,但是这两个Object是创建于不同的域当中,所以使用instanceof检测的结果也不一致。
1 | |
code(在上面这个全局环境中加载的所有代码):这很好理解,就是在环境内的代码,用上面的代码来解释就是:
- 当前页面:上面代码3中所有的代码
iframe页面:var b = {};
state and resources(状态和资源):这里原文没有过多的解释。小呆查询ECMA262原文,猜测可能跟[[LoadedModules]]和[[HostDefined]]两个字段相关。
ES2022中的执行上下文
在ES2022中,执行上下文在ES2018的基础上新增了一个私有环境,其他与ES2018中一致。
Private environment:私有环境(仅包含class生成的私有变量,如无则为null)
总结
网上关于JavaScript执行上下文的文章很多,但是每篇文章可能只讲了一个版本,这对于面试过程中,和考官就存在版本差,如果没有全面的了解不同版本的差异,兴许就会踩坑。其实从ES3一直到ES2022,JavaScript的执行上下文一直在不断的细化和补充,我们从执行上下文这一个点也能看出JavaScript的发展是非常快的。
理解好执行上下文的相关知识,还能从根上解决以下几个问题:
this的指向- 变量提升、函数提升、
let和const到底存不存在提升 - 为什么函数内部能访问到外部的变量(作用域链)
- 闭包
其实本来打算把闭包、作用域链和this在不同场景的指向都在这篇文章中详细展开来写的,但是考虑到文章太长,一时间消化所有知识点不太容易,所以还是决定单独写几篇文章进行梳理。
引用
本文内容参考了以下文章及文档,感谢!感兴趣的同学可以进行阅读!
关于 Realms 理解 ES2018 中的 Realms——作者:nathan96
关于 Private environment:ECMAScript2022 官方文档
关于 Realms:ECMAScript2018 官方文档
关于环境记录器:ECMAScript2015 官方文档












