前言
JavaScript是一门解释性动态语言,但同时它也是一门充满神秘感的语言。如果要成为一名优秀的JS开发者,那么对JavaScript程序的内部执行原理要有所了解。
本文以最新的ECMA规范中的第八章节为基础,理清JavaScript的词法环境和执行上下文的相关内容。这是理解JavaScript其他概念(let/const暂时性死区、变量提升、闭包等)的基础。
本文参考的是最新发布的第十代ECMA-262标准,即ES2019
ES2019与ES6在词法环境和执行上下文的内容上是近似的,ES2019在细节上做了部分补充,因此本文直接采用ES2019的标准。你也可以对比两个版本的标准的差异。
执行上下文(Execution Context)
执行上下文是用来跟踪记录代码运行时环境的抽象概念。每一次代码运行都至少会生成一个执行上下文。代码都是在执行上下文中运行的。
你可以将代码运行与执行上下文的关系类比为进程与内存的关系,在代码运行过程中的变量环境信息都放在执行上下文中,当代码运行结束,执行上下文也会销毁。
在执行上下文中记录了代码执行过程中的状态信息,根据不同运行场景,执行上下文会细分为如下几种类型:
- 全局执行上下文:当运行代码是处于全局作用域内,则会生成全局执行上下文,这也是程序最基础的执行上下文。
- 函数执行上下文:当调用函数时,都会为函数调用创建一个新的执行上下文。
- eval执行上下文:eval函数执行时,会生成专属它的上下文,因eval很少使用,故不作讨论。
执行栈
有了执行上下文,就要有合理管理它的工具。而执行栈(Execution Context Stack
)是用来管理执行期间创建的所有执行上下文的数据结构,它是一个LIFO(后进先出)的栈,它也是我们熟知的JS程序运行过程中的调用栈。
程序开始运行时,会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内。
我们从一小段代码来看下执行栈的工作过程:
当这段JS程序开始运行时,它会创建一个全局执行上下文GlobalContext
,其中会初始化一些全局对象或全局函数,如代码中的console,undefined,isNaN
。将全局执行上下文压入执行栈,通常JS引擎都有一个指针running
指向栈顶元素:
JS引擎会将全局范围内声明的函数(foo
)初始化在全局上下文中,之后开始一行行的执行代码,运行到console
就在running
指向的上下文中的词法环境中找到全局对象console
并调用log
函数。
PS:当然,当调用log
函数时,也是要新建函数上下文并压栈到调用栈中的。这里为了简单流程,忽略了log
上下文的创建过程。
运行到foo()
时,识别为函数调用,此时创建一个新的执行上下文FooContext
并入栈,将FooContext
内词法环境的outer引用指向全局执行上下文的词法环境,移动running
指针指向这个新的上下文:
在完成FooContext
创建后,进入到FooContext
中继续执行代码,运行到bar()
时,同理仍需要新建一个执行上下文BarContext
,此时BarContext
内词法环境的outer引用会指向FooContext
的词法环境:
继续运行bar
函数,由于函数上下文内有outer
引用实现层层递进引用,因此在bar
函数内仍可以获取到console
对象并调用log
。
之后,完成bar
和foo
函数调用,会依次将上下文出栈,直至全局上下文出栈,程序结束运行。
执行上下文的创建
执行上下文创建会做两件事情:
- 创建词法环境
LexicalEnvironment
; - 创建变量环境
VariableEnvironment
;
因此一个执行上下文在概念上应该是这样子的:
ExecutionContext = {
LexicalEnvironment = ,
VariableEnvironment = ,
}
在全局执行上下文中,this指向全局对象,window in browser / global in nodejs
。
参考 前端进阶面试题详细解答
词法环境(LexicalEnvironment)
词法环境是ECMA中的一个规范类型 —— 基于代码词法嵌套结构用来记录标识符和具体变量或函数的关联。
简单来说,词法环境就是建立了标识符——变量的映射表。这里的标识符指的是变量名称或函数名,而变量则是实际变量原始值或者对象/函数的引用地址。
在LexicalEnvironment
中由两个部分构成:
- 环境记录
EnvironmentRecord
:存放变量和函数声明的地方; - 外层引用
outer
:提供了访问父词法环境的引用,可能为null;
this绑定ThisBinding
:确定当前环境中this的指向,this binding存储在EnvironmentRecord中;
词法环境的类型
- 全局环境(
GlobalEnvironment
):在JavaScript代码运行伊始,宿主(浏览器、NodeJs等)会事先初始化全局环境,在全局环境的EnvironmentRecord
中会绑定内置的全局对象(Infinity
等)或全局函数(eval
、parseInt
等),其他声明的全局变量或函数也会存储在全局词法环境中。全局环境的outer
引用为null
。
这里提及的全局对象就有我们熟悉的所有内置对象,如Math、Object、Array等构造函数,以及Infinity等全局变量。全局函数则包含了eval、parseInt等函数。
- 模块环境(
ModuleEnvironment
):你若写过NodeJs程序就会很熟悉这个环境,在模块环境中你可以读取到export
、module
等变量,这些变量都是记录在模块环境的ER中。模块环境的outer
引用指向全局环境。 - 函数环境(
FunctionEnvironment
):每一次调用函数时都会产生函数环境,在函数环境中会涉及this
的绑定或super
的调用。在ER中也会记录该函数的length
和arguments
属性。函数环境的outer
引用指向调起该函数的父环境。在函数体内声明的变量或函数则记录在函数环境中。
环境记录ER
代码中声明的变量和函数都会存放在EnvironmentRecord
中等待执行时访问。
环境记录EnvironmentRecord
也有两个不同类型,分别为declarative
和object
。declarative
是较为常见的类型,通常函数声明、变量声明都会生成这种类型的ER。object
类型可以由with
语句触发的,而with
使用场景很少,一般开发者很少用到。
如果你在函数体中遇到诸如var const let class module import 函数声明
,那么环境记录就是declarative
类型的。
值得一提的是全局上下文的ER
有一点特殊,因为它是object ER
与declarative ER
的混合体。在object ER
中存放的是全局对象函数、function函数声明、async
、generator
、var
关键词变量。在declarative ER
则存放其他方式声明的变量,如let const class
等。由于标准中将object
类型的ER视作基准ER,因此这里我们仍将全局ER的类型视作object
。
GlobalExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
type: 'object', // 混合 object + declarative
this: ,
NaN,
parseInt,
Object,
myFunc,
a,
b,
...
},
outer: null,
}
}
LexicalEnvironment
只存储函数声明和let/const
声明的变量,与下文的VariableEnvironment
有所区别。
比如,我们有如下代码:
let a = 10;
function foo(){
let b = 20
console.log(a, b)
}
foo()
// 它们的词法环境伪码如下:
GlobalEnvironment: {
EnvironmentRecord: {
type: 'object',
this: ,
a: ,
foo:
},
outer:
}
FunctionEnvironment: {
EnvironmentRecord: {
type: 'declarative',
this: , // 严格模式下为undefined
arguments: {length: 0},
b:
},
outer:
}
函数环境记录
由于函数环境是我们日常开发过程最常见的词法环境,因此需要更加深入的研究一下函数环境的运行机制,帮助我们更好理解一些语言特性。
当我们调用一个函数时,会生成函数执行上下文,这个函数执行上下文的词法环境的环境记录就是函数类型的,有点拗口,用树形图代表一下:
FunctionContext
|LexicalEnvironment
|EnvironmentRecord //--> 函数类型
为什么要强调这个类型呢?因为ECMA针对函数式环境记录会额外增加一些内部属性:
内部属性 | Value | 说明 | 补充 |
---|---|---|---|
[[ThisValue]] |
Any |
函数内调用this 时引用的地址,我们常说的函数this 绑定就是给这个内部属性赋值 |
|
[[ThisBindingStatus]] |
"lexical" / "initialized" / "uninitialized" |
若等于lexical ,则为箭头函数,意味着this 是空的; |
强行new 箭头函数会报错TypeError 错误 |
FunctionObject |
Object |
在这个对象中有两个属性[[Call]] 和[[Construct]] ,它们都是函数,如何赋值取决于如何调用函数 |
正常的函数调用赋值[[Call]] ,而通过new 或super 调用函数则赋值[[Construct]] |
[[HomeObject]] |
Object / undefined |
如果该函数(非箭头函数)有super 属性(子类),则[[HomeObject]] 指向父类构造函数 |
若你写过extends 就知道我在说什么 |
[[NewTarget]] |
Object / undefined |
如果是通过[[Construct]] 方式调用的函数,那么[[NewTarget]] 非空 |
在函数中可以通过new.target 读取到这个内部属性。以此来判断函数是否通过new 来调用的 |
此外,函数环境记录中还存有一个arguments对象,记录了函数的入参信息。
ThisBinding
this绑定是一个老生常谈的问题,由于存在多种分析场景,这里不便展开,this绑定的目的是在执行上下文创建之时就明确this的指向,在函数执行过程中读取到正确的this引用的对象。
小结
概念类型太多,有一些凌乱了。简单速记一下:
词法环境分类 = 全局 / 函数 / 模块
词法环境 = ER + outer + this
ER分类 = declarative(DER) + object(OER)
全局ER = DER + OER
VariableEnvironment 变量环境
在ES6前,声明变量都是通过var
关键词声明的,在ES6中则提倡使用let
和const
来声明变量,为了兼容var
的写法,于是使用变量环境来存储var
声明的变量。
var
关键词有个特性,会让变量提升,而通过let/const
声明的变量则不会提升。为了区分这两种情况,就用不同的词法环境去区分。
变量环境本质上仍是词法环境,但它只存储var
声明的变量,这样在初始化变量时可以赋值为undefined
。
有了这些概念,一个完整的执行上下文应该是什么样子的呢?来点例子:
let a = 10;
const b = 20;
var sum;
function add(e, f){
var d = 40;
return d + e + f
}
let utils = {
add
}
sum = utils.add(a, b)
完整的执行上下文如下所示:
GlobalExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
type: 'object',
this: ,
add: ,
a: ,
b: ,
utils:
},
outer: null
},
VariableEnvironment: {
EnvironmentRecord: {
type: 'object',
this:
sum: undefined
},
outer: null
},
}
// 当运行到函数add时才会创建函数执行上下文
FunctionExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
type: 'declarative',
this: ,
arguments: {0: 10, 1: 20, length: 2},
[[NewTarget]]: undefined,
e: 10,
f: 20,
...
},
outer:
},
VariableEnvironment: {
EnvironmentRecord: {
type: 'declarative',
this:
d: undefined,
},
outer:
},
}
执行上下文创建后,进入到执行环节,变量在执行过程中赋值、读取、再赋值等。直至程序运行结束。
我们注意到,在执行上下文创建时,变量a
`b都是
的,而
sum则被初始化为
undefined。这就是为什么你可以在声明之前访问
var定义的变量(变量提升),而访问
let/const`定义的变量就会报引用错误的原因。
let/const 与 var
简单聊聊同是变量声明,两者有何区别?
let 与 const 的区别这里不再赘述
存放位置
从上一结中,我们知道了let/const
声明的变量是归属于LexicalEnvironment
,而var
声明的变量归属于VariableEnvironment
。
初始化(词法阶段) let/const
在初始化时会被置为
标志位,在没有执行到let xxx
或 let xxx = ???
(赋值行)的具体行时,提前读取变量会报ReferenceError
的错误。(这个特性又叫暂时性死区
) var
在初始化时先被赋值为undefined
,即使没有执行到赋值行,仍可以读取var
变量(undefined
)。
块环境记录(块作用域)
在ECMA标准中提到,当遇到Block
或CaseBlock
时,将会新建一个环境记录,在块中声明的let/const
变量、函数、类都存放这个新的环境记录中,这些变量与块强绑定,在块外界则无法读取这些声明的变量。这个特性就是我们熟悉的块作用域。
什么是Block?
被花括号({})括起来的就是块。
在Block
中的let/const
变量仅在块中有效,块外界无法读取到块内变量。var
变量不受此限制。
var
不管在哪,都会变量提升~
与ES3的区别
如果你了解ES5版本的有关执行上下文的内容,会感到奇怪为啥有关VO
、AO
、作用域、作用域链等内容没有在本文中提及。其实两者概念并不冲突,一个是ES3规范中的定义,而词法环境则是ES6规范的定义。不同时期,不同称呼。
ES3 --> ES6
作用域 --> 词法环境
作用域链 --> outer引用
VO|AO --> 环境记录
你问我该学哪个?立足现在,铭记历史,拥抱未来。
总结
本文关于执行上下文的理论知识比较多,不容易马上吸收理解,建议你逐渐消化、反复阅读理解。当你熟悉了执行上下文和词法环境,相信去理解认识更多JS特性和概念时,会更加轻松容易。