理解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 官方文档