前言
接下来我们来啃一个硬骨头,JavaScript 的 执行上下文(Execution Context)。
与执行上下文相关的知识有很多弯弯绕绕,不过没关系,我们只需要以两个主要问题为线索展开。第一个问题,执行上下文是干什么用的? 根据多个版本的 ES 规范,可以得出一个一脉相承的答案——
“执行上下文是用于追踪运行时代码执行过程的一个基础设施。”
第二个问题是, 执行上下文里有什么?
这个问题就不好回答了,因为 ES 规范在不断迭代,它的答案也在不断发生变化。不同版本的概念相互交叉,导致网上相关问题的杂音特别多,如果我们不是特别清楚怎么回事,就难以分辨对错。
这个部分主要参考 ES 3、ES 6、ES 2020 三个版本,ES 3 和 ES 6 是经典版本,ES 2020 是最新版本,把它们放在一起来看,也能够比较完整地反映 ES 的演变过程。
直接按列表去解释执行上下文中各个组成部分会比较晦涩,咱们还是以一些经典的概念和问题为线索,来逐步把这块知识地图补全。
1 作用域链与词法环境
“作用域链”(scope chain) 这个词想必大家都听说过,要想搞清楚它的含义,我们需要追溯到 ES 3 时代:
A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects, depending on the type of code. 1
作用域链是计算标识符(也就是根据变量名查找变量值)时使用的对象列表。当控制台进入一个执行上下文时就会创建一个作用域链,并根据代码类型填入一组初始初始对象。
在这个时期,作用域链是在执行上下文中真实存在的一个数据结构,可以理解为一个对象(引用)列表。同时,Function 对象有一个私有字段 [[Scope]]
,存放的也是作用域链,这个[[Scope]]
跟在函数代码被执行时创建的 activation object
后面,就形成了执行上下文中的作用域链。
这个 activation object
,在变量实例化时也称 variable object
,用来存储当前上下文中变量的值,可以理解为对应了当前被调用的函数本身。在这个对象里找不到的值,才会沿着链向外层寻找。
[[Scope]]
这个字段是函数本身的东西,也就是说,在函数被定义时,函数的作用域链就被确定了。函数的调用方式(调用位置)对变量查找的结果没有影响。
讲到这里我们似乎应该举个例子,但是别急,上面的说法是过时的,在 ES 6 规范里根本没有 scope chain 这个概念。
然而,即使背后的实现方式不同,我们依然可以用同样的思路去分析当前执行的代码如何去查找变量。不较真的话,甚至仍然可以假设 scope chain 是存在的,或者说 scope chain 从一种真实存在于内存中的数据变成了一种抽象的概念。
那么 ES 6 是如何保持这种一致性的呢?我们先来看一下 ES 6 的执行上下文 2 里都有什么东西。
- code evaluation state,代码执行的状态(执行、挂起、恢复等)。
- Function,当前执行的函数。如果当前执行的是脚本(Script)或模块(Module)中的代码,那这个组件(或说属性)的值就是 null。
- Realm,代码所在的 “领域”。一段代码只能访问本领域的资源,可以把浏览器中的一个 window 理解为一个 Realm。
- LexicalEnvironment,标识该执行上下文中的 词法环境(Lexical Environment),词法环境主要用来解析标识符。
- VariableEnvironment,标识该执行上下文中包含由变量声明(VariableStatements,也就是
var
声明)创建的变量的词法环境。 - Generator,执行 Generator 时会有这个额外的组件,值为当前执行的 Generator 对象。
看起来与作用域链功能相关的就是词法环境(和变量环境)。词法环境被归类于一种 “规范类型”,它包含两个部分:
- Environment Record,记录在词法环境作用域中创建的标识符绑定信息。
- 一个指向外层词法环境的引用,我们可以称之为 “outer”。
这个 outer 就是 scope chain 的替代者,其实就是从存储一条链上的所有函数对象(引用)改进为存储外面一层的词法环境(引用)。如果一个函数没有外层函数,它的词法环境的 outer 就指向全局词法环境,全局词法环境的 outer 则指向 null。
outer 用于实现 “作用域链”,而词法环境(变量环境)本身则正是变量的 “作用域”。
搞清楚了 作用域与作用域链 的来龙去脉,我们再来看看具体的例子。
// 定义 foo 函数
function foo() {
console.log(site);
}
// 定义 bar 函数
function bar() {
let site = "github";
foo();
}
// 全局变量 site
let site = "csdn";
// 调用 bar 函数
bar();
调用 bar 函数的过程如下:
- 创建 bar 函数的执行上下文并入栈,成为栈顶(当前执行上下文)
- 执行到
foo()
,创建 foo 函数的执行上下文并入栈,成为新的栈顶 - 由于 foo 函数在定义时并没有 site 这个变量,因此需要通过 outer 向外层寻找
- 由于 foo 是一个定义在全局里的函数,它的 outer 指向全局执行上下文的词法环境,全局词法环境中存在变量
site: "csdn"
,因此获取到的值就是"csdn"
2 变量提升与块级作用域
通过上面的探讨,我们知道了词法环境的作用。但是另外一个组件——变量环境(VariableEnvironment),我们说是指向了包含了使用 var 声明创建的变量的词法环境。为什么要这么设计呢?
这是因为使用 var 和使用 let + const 声明变量的背后不是一套机制,仅仅使用一个词法环境是不够的,无法保证 向下兼容。这也是 JS 的一个历史包袱,说白了就是语言初期使用 var 来声明变量的设计存在缺陷,因此在 ES 6 又引入了 let 和 const 两个关键字去补全。
var 的缺陷在哪里呢?一是存在所谓 “变量提升” 这样一种诡异的机制,二是无法实现块级作用域。
2.1 变量提升
变量提升这个词的英文是 Hoisting,意思是把什么东西吊起来,提起来。它是一种 “民间说法”,并未作为术语在 ES 规范中出现过(ES 2015 中只是提了一下这个词)。
它的含义是,一个 var 变量的声明不论写在哪里,都好像被提溜到了最前面一样,使得它可以在被声明语句执行之前就被访问到。
正常我们使用一个变量,应该是声明、赋值、调用,比如
var hello = "Hello world";
console.log()
我们使用一个根本没有声明的变量时,会报变量未声明的错误。
console.log(hello); // Uncaught ReferenceError: hello is not defined
但是如果我们在调用之后去声明这个变量,那输出就会变成未定义,也就是存在这个变量,但还没有定义它的类型。
console.log(hello); // undefined
var hello = "Hello world";
而如果我们先赋值,再调用,最后声明,就能获取到变量的值。
hello = "Hello world";
console.log(hello); // Hello world
var hello;
为什么说这种机制是一种缺陷呢?因为它很容易导致不符合正常逻辑的结果。比如
var hello = "Hello world";
function sayHello() {
console.log(hello); // undefined
var hello = "Hello earth";
console.log(hello); // Hello earth
}
sayHello();
本来如果函数内没有 hello 的定义,第一次输出时会从全局执行上下文中找 hello 的值。而函数内有了定义,第一次输出时就会从当前上下文中寻找该变量,由于调用在赋值之前,所以输出的就是 undefined。
这种现象是如何产生的呢?我们继续刨根问底,关于变量声明,ES 3 描述道:3
当变量声明在函数声明内出现时,变量的作用域将是 函数作用域,否则则为 全局作用域。变量在执行作用域入栈(也就是执行上下文入栈)时被创建,同时,块(Block)并不会定义一个新的执行作用域。
变量创建时被初始化为 undefiend,即使一个变量声明附带初始化语句,在变量创建的阶段也无法获取到声明的值。
也就是说,变量在当前执行上下文入栈时就被找出来并初始化为 undefined 了,不需要等到相应的声明语句被执行,而声明语句中初始化的值则需要该语句执行时才能获得。
有点鹦鹉学舌了,总之就是因为这种设计,才导致出现了变量提升这个现象。至于为什么这么设计,可能是仓促之间失误了,也可能当年设计者就想让 JS 的代码写起来随意一些。
到了 ES 6,虽然设计者有意纠正这个问题,但毕竟旧账太多,只能考虑两套系统并行,于是才在执行上下文中放入了一个 VariableEnvironment,用来延续这种机制。
ES 3 的这段话同时还提到 Block 不会定义一个新的作用域,我们再来看看这个问题。
2.2 块级作用域
我们可以先来想一下为什么 ES 3 不支持块级作用域。
根据前面的内容应该可以想到,ES 3 在执行到 Block 时不会创建一个新的作用域,更不会创建新的执行上下文。而按照设计,与当前执行上下文相关的 var 变量要在执行上下文入栈时就被初始化,在这种限定下根本就实现不了块作用域。
ES 6 是如何做的呢?我们来看看关于词法环境(Lexical Environment)创建的描述 4
通常,一个词法环境与一些特殊的语法结构相关联,比如 函数声明、Block 声明、Catch 块等。当这些代码被执行时,会 创建新的词法环境。
既然在 ES 6 种词法环境就是作用域,那就让 Block 声明也能创建词法环境呗。这里还是要强调一下,规范中有 LexicalEnvironment 和 Lexical Environment 两种写法,虽然翻译都是词法环境,但其实前者是指执行上下文中的组件名,后者是指词法环境这种类型本身,很多人把它们混淆了,所以会有一些误读。
我们可以认为 LexicalEnvironment 是一个栈结构,其内部是当前执行上下文对应的所有 Lexical Environment。也就是说,执行当前块的代码时,会向栈顶压入一个新的词法环境,当当前块执行结束后,这个词法环境就被弹出了,整个栈里面就没有这个变量了。
这个词法环境栈就像是一个小型的执行上下文栈。其实上面规范里的描述不准确,应该是函数声明创建执行上下文,同时创建词法环境,而 Block 声明之类的用 {}
括起来的代码则只创建词法环境。
OK,执行上下文利用这种内部结构,解决了块作用域的问题。那么变量提升怎么处理呢?我们再来看下关于 let 和 const 声明的定义: 5
let 和 const 声明用于定义作用域为当前执行上下文的词法环境的变量。这些变量在它们所属词法环境初始化时被创建,但在变量声明被执行(这个过程称为词法绑定,LexicalBinding)之前无法通过任何方式访问。
带初始化语句的声明也是在词法绑定执行后方能把值给到变量。如果没有初始化语句,则词法绑定时将变量置为 null。
用 let 和 const 声明的变量仍然是提前被创建的,但是加了一个限定,在声明语句被执行之前不能访问。实现上就是如果在声明执行之前访问会报错,这也就避免了变量提升问题。这里 node 和 v8 报错的表现不一样,大家可以尝试一下。
这段话同时也指出了 let 和 const 声明的变量是随词法环境的创建而创建的,因此并不会多管闲事把内部块级作用域里的变量也给创建了。
这个部分的内容不仅适用于 ES 6,我特地看了下,ES 2020 里相关的描述一个字也没有变。
今天的 JS 编码规范大多推荐使用 let + const 关键字,也许在将来的某一天会有废弃掉 var 的条件,这样就也不用在执行上下文中保留 VariableEnvironment 了。
下一步
看来执行上下文用五千字还是讲不完的,那咱们下周继续吧。还剩下闭包、this 机制两个部分,咱们还是用规范正本清源。
各位也可以想一想,如果有人让你解释一下作用域链、变量提升、块级作用域,你该怎么回答才能比较稳妥?欢迎在评论区留言。
虽然估计除了 YXH 也不会有人留言。。。
说实话,我在全网还没有见过一篇关于执行上下文的文章是完全遵照规范去讲的,虽说规范里的设计并不一定是最好的, JS 引擎也不一定真是这么实现的,但有很多说法显然是一种想当然的推断,不应该被广泛流传。
由于对部分概念的理解没有规范之外的参考资料,个人水平所限,我不能保证自己就是完全正确的,因此强烈建议看到这篇文章的同学去阅读一下规范原文的相关章节。这部分看一看还是很有好处的,能把运行时机制写地这么详细的语言规范也是不多。
参考内容:
Standard ECMA-262 3rd Edition - December 1999, 10.1.4 Scope Chain and Identifier Resolution. ↩︎
Standard ECMA-262 6th Edition - June 2015 (ECMAScript 2015 Language Specification), 8.3 Execution Contexts. ↩︎
Standard ECMA-262 3rd Edition - December 1999, 12.2 Variable statement. ↩︎
Standard ECMA-262 6th Edition - June 2015 (ECMAScript 2015 Language Specification), 8.1 Lexical Environments. ↩︎
Standard ECMA-262 6th Edition - June 2015 (ECMAScript 2015 Language Specification), 13.3.1 Let and Const Declarations. ↩︎