JavaScript是一门解释性动态语言,但同时它也是一门充满神秘感的语言。若是要成为一名优秀的JS开发者,那么对JavaScript程序的内部执行原理要有所了解。javascript
本文以最新的ECMA规范中的第八章节为基础,理清JavaScript的词法环境和执行上下文的相关内容。这是理解JavaScript其余概念(let/const暂时性死区、变量提高、闭包等)的基础。html
本文参考的是最新发布的第十代ECMA-262标准,即ES2019,点击官方文档地址。 ES2019与ES6在词法环境和执行上下文的内容上是近似的,ES2019在细节上作了部分补充,所以本文直接采用ES2019的标准。你也能够对比两个版本的标准的差别。前端
执行上下文是用来跟踪记录代码运行时环境的抽象概念。每一次代码运行都至少会生成一个执行上下文。代码都是在执行上下文中运行的。java
你能够将代码运行与执行上下文的关系类比为进程与内存的关系,在代码运行过程当中的变量环境信息都放在执行上下文中,当代码运行结束,执行上下文也会销毁。node
在执行上下文中记录了代码执行过程当中的状态信息,根据不一样运行场景,执行上下文会细分为以下几种类型:git
有了执行上下文,就要有合理管理它的工具。而执行栈(Execution Context Stack
)是用来管理执行期间建立的全部执行上下文的数据结构,它是一个LIFO(后进先出)的栈,它也是咱们熟知的JS程序运行过程当中的调用栈。 程序开始运行时,会先建立一个全局执行上下文并压入到执行栈中,以后每当有函数被调用,都会建立一个新的函数执行上下文并压入栈内。github
咱们从一小段代码来看下执行栈的工做过程:浏览器
<script> console.log('script') function foo(){ function bar(){ console.log('bar', isNaN(undefined)) } bar() console.log('foo') } foo() </script> 复制代码
当这段JS程序开始运行时,它会建立一个全局执行上下文GlobalContext
,其中会初始化一些全局对象或全局函数,如代码中的console,undefined,isNaN
。将全局执行上下文压入执行栈,一般JS引擎都有一个指针running
指向栈顶元素:markdown
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 = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>,
}
复制代码
在全局执行上下文中,this指向全局对象,window in browser / global in nodejs
。
词法环境是ECMA中的一个规范类型 —— 基于代码词法嵌套结构用来记录标识符和具体变量或函数的关联。 简单来讲,词法环境就是创建了标识符——变量的映射表。这里的标识符指的是变量名称或函数名,而变量则是实际变量原始值或者对象/函数的引用地址。
在LexicalEnvironment
中由三个部分构成:
EnvironmentRecord
:存放变量和函数声明的地方;outer
:提供了访问父词法环境的引用,可能为null;ThisBinding
:肯定当前环境中this的指向;词法环境的类型
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 NaN, parseInt, Object, myFunc, a, b, ... }, outer: null, this: <globalObject> } } 复制代码
LexicalEnvironment
只存储函数声明和let/const
声明的变量,与下文的VariableEnvironment
有所区别。
好比,咱们有以下代码:
let a = 10; function foo(){ let b = 20 console.log(a, b) } foo() // 它们的词法环境伪码以下: GlobalEnvironment: { EnvironmentRecord: { type: 'object', a: <uninitialized>, foo: <func> }, outer: <null>, this: <globalObject> } FunctionEnvironment: { EnvironmentRecord: { type: 'declarative', arguments: {length: 0}, b: <uninitialized>, }, outer: <GlobalEnvironment>, this: <globalObject> // 严格模式下为undefined } 复制代码
函数环境记录
因为函数环境是咱们平常开发过程最多见的词法环境,所以须要更加深刻的研究一下函数环境的运行机制,帮助咱们更好理解一些语言特性。
当咱们调用一个函数时,会生成函数执行上下文,这个函数执行上下文的词法环境的环境记录就是函数类型的,有点拗口,用树形图表明一下:
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绑定是一个老生常谈的问题,因为存在多种分析场景,这里不便展开。👉 《JS夯实之ThisBinding的四条准则》 this绑定的目的是在执行上下文建立之时就明确this的指向,在函数执行过程当中读取到正确的this引用的对象。
小结
概念类型太多,有一些凌乱了。简单速记一下:
词法环境分类 = 全局 / 函数 / 模块 词法环境 = ER + outer + this ER分类 = declarative(DER) + object(OER) 全局ER = DER + OER 复制代码
在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', add: <function>, a: <uninitialized>, b: <uninitialized>, utils: <uninitialized>, }, outer: null, this: <globalObject> }, VariableEnvironment: { EnvironmentRecord: { type: 'object', sum: undefined }, outer: null, this: <globalObject> }, } // 当运行到函数add时才会建立函数执行上下文 FunctionExecutionContext = { LexicalEnvironment: { EnvironmentRecord: { type: 'declarative', arguments: {0: 10, 1: 20, length: 2}, [[ThisValue]]: <utils>, [[NewTarget]]: undefined, ... }, outer: <GlobalLexicalEnvironment>, this: <utils> }, VariableEnvironment: { EnvironmentRecord: { type: 'declarative', d: undefined }, outer: <GlobalLexicalEnvironment>, this: <utils> }, } 复制代码
执行上下文建立后,进入到执行环节,变量在执行过程当中赋值、读取、再赋值等。直至程序运行结束。 咱们注意到,在执行上下文建立时,变量a``b
都是<uninitialized>
的,而sum
则被初始化为undefined
。这就是为何你能够在声明以前访问var
定义的变量(变量提高),而访问let/const
定义的变量就会报引用错误的缘由。
简单聊聊同是变量声明,二者有何区别?
let 与 const 的区别这里再也不赘述
存放位置
从上一结中,咱们知道了let/const
声明的变量是归属于LexicalEnvironment
,而var
声明的变量归属于VariableEnvironment
。
初始化(词法阶段)
let/const
在初始化时会被置为<uninitialized>
标志位,在没有执行到let xxx
或 let xxx = ???
(赋值行)的具体行时,提早读取变量会报ReferenceError
的错误。(这个特性又叫暂时性死区
) var
在初始化时先被赋值为undefined
,即便没有执行到赋值行,仍能够读取var
变量(undefined
)。
块环境记录(块做用域)
在ECMA标准中提到,当遇到Block
或CaseBlock
时,将会新建一个环境记录,在块中声明的let/const
变量、函数、类都存放这个新的环境记录中,这些变量与块强绑定,在块外界则没法读取这些声明的变量。这个特性就是咱们熟悉的块做用域。
什么是Block? 被花括号({})括起来的就是块。
在Block
中的let/const
变量仅在块中有效,块外界没法读取到块内变量。var
变量不受此限制。
var
无论在哪,都会变量提高~
若是你了解ES5版本的有关执行上下文的内容,会感到奇怪为啥有关VO
、AO
、做用域、做用域链等内容没有在本文中说起。其实二者概念并不冲突,一个是ES3规范中的定义,而词法环境则是ES6规范的定义。不一样时期,不一样称呼。
ES3 --> ES6 做用域 --> 词法环境 做用域链 --> outer引用 VO|AO --> 环境记录 这里有个stackoverflow的讨论
你问我该学哪一个?立足如今,铭记历史,拥抱将来。
本文关于执行上下文的理论知识比较多,不容易立刻吸取理解,建议你逐渐消化、反复阅读理解。当你熟悉了执行上下文和词法环境,相信去理解认识更多JS特性和概念时,会更加轻松容易。
码字不易,若是:
您的支持与关注,是我持续创做的最大动力!
本文首发于个人Blog仓库