理解 JavaScript 中的执行上下文和执行栈
Category(分类): JavaScript Status: 已整理
本文保留了译文(掘金翻译计划,2018)的完整结构与全部示例代码。整理时更正了原译文沿用的几处技术错误(全局环境中 let/const 的存放位置、伪代码
ExectionContext拼写、「全局执行上下文创建 window 对象」等),补充模块上下文、箭头函数、globalThis等现代内容;原文插图所在的 Medium 图床已失效,执行栈示意图改为文字版。文末附勘误清单。
如果你是或者想成为一名 JavaScript 开发者,你必须知道 JavaScript 程序内部是如何执行的。理解执行上下文和执行栈对于理解其他 JavaScript 概念(如变量声明提升,作用域和闭包)至关重要。
正确理解执行上下文和执行栈的概念将使您成为更出色的 JavaScript 开发者。
闲话少说,让我们开始吧 :)
什么是执行上下文?
简而言之,执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 JavaScript 代码在运行的时候,它都是在执行上下文中运行。
执行上下文的类型
JavaScript 中有三种执行上下文类型。
- 全局执行上下文 — 这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置
this的值等于这个全局对象。一个程序中只会有一个全局执行上下文。 - 函数执行上下文 — 每当一个函数被调用时,都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
- Eval 函数执行上下文 — 执行在
eval函数内部的代码也会有它属于自己的执行上下文,但由于 JavaScript 开发者并不经常使用eval,所以在这里我不会讨论它。
现代勘误:
- 「全局执行上下文创建一个全局的 window 对象」表述不准确——全局对象是随着 realm(领域,含全局对象、内建对象与全局环境) 在进入任何代码执行之前就被创建好的宿主设施,全局执行上下文只是把
this绑定到它。而且全局对象不叫 window:浏览器窗口是window,Worker 里是self,Node.js 里是global,ES2020 起可用统一的名字globalThis访问。- ES2015 之后还应补充第四类:模块执行上下文(module context)。
<script type="module">或 ESM 文件有自己的模块级环境,模块顶层this是undefined而不是全局对象,模块间的执行顺序由依赖图决定。
执行栈
执行栈,也就是在其它编程语言中所说的「调用栈」,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。
引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。
让我们通过下面的代码示例来理解:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
(原文此处的执行栈示意图来自 Medium 图床,现已失效;以下用文字版还原同样的内容。)
执行过程(栈自下而上生长):
① 脚本启动 [ Global EC ] 输出:
② 调用 first() [ Global EC ][ first EC ] -
③ 执行 first 体 [ Global EC ][ first EC ] "Inside first function"
④ 调用 second() [ Global EC ][ first EC ][ second EC ]-
⑤ second 执行完 [ Global EC ][ first EC ] ← 弹出 "Inside second function"
⑥ 回到 first 继续 [ Global EC ][ first EC ] "Again inside first function"
⑦ first 执行完 [ Global EC ] ← 弹出 -
⑧ 脚本收尾 [ Global EC ] "Inside Global Execution Context"
⑨ 全部结束 [](全局上下文也随之移除)
上述代码的执行上下文栈。
当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。
当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。
当 first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。
现代注解:想在运行时观察这个栈,最直接的办法是
console.log(new Error().stack)(或任何未捕获错误的.stack),它打印的就是当前的调用栈帧,与上面示意图一一对应。
怎么创建执行上下文?
到现在,我们已经看过 JavaScript 怎样管理执行上下文了,现在让我们了解 JavaScript 引擎是怎样创建执行上下文的。
创建执行上下文有两个阶段:1) 创建阶段 和 2) 执行阶段。
现代注解:「创建/执行两阶段」是 ES5 时代总结的心智模型,现行规范里并没有叫「创建阶段」的正式步骤,而是描述为执行上下文被构造时各组件(代码评估状态、Function/Realm、词法环境、变量环境)的初始化,以及函数调用时的 FunctionDeclarationInstantiation 等过程。两阶段模型解释提升行为依然好用,但别把它当成规范原文。
The Creation Phase
在 JavaScript 代码执行前,执行上下文将经历创建阶段。在创建阶段会发生三件事:
- this 值的决定,即我们所熟知的 This 绑定。
- 创建词法环境组件。
- 创建变量环境组件。
所以执行上下文在概念上表示如下:
ExecutionContext = {
ThisBinding: <this value>,
LexicalEnvironment: { /* ... */ },
VariableEnvironment: { /* ... */ }
}
(原文伪代码中的 ExectionContext 为拼写错误,已更正为 ExecutionContext。)
This 绑定:
在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this 引用 Window 对象。)
在函数执行上下文中,this 的值取决于该函数是如何被调用的。如果它被一个引用对象调用,那么 this 会被设置成那个对象,否则 this 的值被设置为全局对象或者 undefined(在严格模式下)。例如:
let foo = {
baz: function() {
console.log(this)
}
}
foo.baz() // 'this' 引用 'foo',因为 'baz' 被
// 对象 'foo' 调用
let bar = foo.baz
bar() // 'this' 指向全局对象(浏览器 window / Node globalThis),
// 因为没有指定引用对象;严格模式下则是 undefined
现代注解:完整的 this 判定还应加上三条——箭头函数没有自己的 this,沿词法作用域取外层的 this;
class中的方法体默认按严格模式处理(顶层this不会变成全局对象);call/apply/bind与Reflect.apply可以显式指定 this。原文示例是非严格模式的经典行为,注释里已补严格模式差异。
词法环境
官方的 ES6 文档把词法环境定义为:
词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来说,词法环境是一种持有标识符—变量映射的结构。(这里的标识符指的是变量/函数的名字,而变量是对实际对象包含函数类型对象或原始数据的引用。)
现在,在词法环境的内部有两个组件:(1) 环境记录器和 (2) 一个外部环境的引用。
- 环境记录器是存储变量和函数声明的实际位置。
- 外部环境的引用意味着它可以访问其父级词法环境(作用域)。
词法环境有两种类型:
- 全局环境(在全局执行上下文中)是没有外部环境引用的词法环境。全局环境的外部环境引用是 null。它拥有内建的 Object/Array/等、在环境记录器内的原型函数(关联全局对象,比如 window 对象)还有任何用户定义的全局变量,并且
this的值指向全局对象。 - 在函数环境中,函数内部用户定义的变量存储在环境记录器中。并且引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数。
环境记录器也有两种类型(如上!):
- 声明式环境记录器存储变量、函数和参数。
- 对象环境记录器用来定义出现在全局上下文中的变量和函数的关系。
简而言之:
- 在全局环境中,环境记录器是对象环境记录器。
- 在函数环境中,环境记录器是声明式环境记录器。
现代勘误(重要):上面这两条「简而言之」是原文沿用的错误简化。全局环境其实同时拥有一个对象环境记录器(管理
var、函数声明——它们会成为全局对象的属性)和一个声明式环境记录器(管理let/const/class——它们不会成为window的属性)。验证很简单:
var v = 1
let l = 2
console.log(globalThis.v) // 1 —— var 进入对象记录器,挂在全局对象上
console.log(globalThis.l) // undefined —— let 在声明式记录器里,不挂属性
注意验证环境:上面两条结果适用于浏览器经典
<script>(非模块)。在 Node 的 CommonJS 模块里,模块代码被包在函数作用域内,var也不会挂到globalThis;ESM 顶层同样不挂。
注意 — 对于函数环境,声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。
现代注解:
arguments的「索引与形参联动」只发生在非严格模式且形参列表简单的函数里(mapped arguments);严格模式或使用默认参数/剩余参数后,arguments与形参不再联动。箭头函数则根本没有arguments。
抽象地讲,词法环境在伪代码中看起来像这样:
GlobalExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object + Declarative", // 见上文勘误:var/函数声明在对象记录器,let/const/class 在声明式记录器
// 在这里绑定标识符
},
outer: <null>
}
}
FunctionExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
},
outer: <Global or outer function environment reference>
}
}
(原文伪代码中的 GlobalExectionContext / FunctionExectionContext 拼写已更正。)
变量环境:
它同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。
如上所述,变量环境也是一个词法环境,所以它有着上面定义的词法环境的所有属性。
在 ES6 中,词法环境组件和变量环境的一个不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储 var 变量绑定。
现代注解:再精确一点:函数顶层的函数声明与
var一样进入变量环境(这就是「函数提升优先于变量提升」的来源),let/const/class以及块级作用域内的声明进入词法环境。每进入一个新块({})会创建新的词法环境,但变量环境不变。
我们看点样例代码来理解上面的概念:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
执行上下文看起来像这样:
GlobalExecutionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative", // let/const 在全局的声明式记录器中,不挂在全局对象上
a: <uninitialized>, // 原文把 a、b 画在 Object 记录器里,已按规范更正
b: <uninitialized>,
multiply: <func>
},
outer: <null>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
c: undefined
},
outer: <null>
}
}
FunctionExecutionContext = {
ThisBinding: <Global Object>, // 非严格模式;严格模式下是 undefined
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
Arguments: { 0: 20, 1: 30, length: 2 }
},
outer: <GlobalLexicalEnvironment>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}
注意 — 只有遇到调用函数 multiply 时,函数执行上下文才会被创建。
可能你已经注意到 let 和 const 定义的变量并没有关联任何值,但 var 定义的变量被设成了 undefined。
这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为 undefined(var 情况下),或者未初始化(let 和 const 情况下)。
这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误。
这就是我们说的变量声明提升(以及 let/const 的暂时性死区 TDZ)。
执行阶段
这是整篇文章中最简单的部分。在此阶段,完成对所有这些变量的分配,最后执行代码。
注意 — 在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined。
现代勘误:这句译文容易误读。准确含义是:声明不带初始化器(如
let x;)时,执行到声明那一行会把x初始化为undefined;而不是「引擎找不到值就随便给 undefined」。在声明行之前访问(TDZ 内)抛的是ReferenceError,两种情况不要混。
结论
我们已经讨论过 JavaScript 程序内部是如何执行的。虽然要成为一名卓越的 JavaScript 开发者并不需要学会全部这些概念,但是如果对上面概念能有不错的理解将有助于你更轻松,更深入地理解其他概念,如变量声明提升,作用域和闭包。
就是这样,如果你发现这篇文章有用,请点击 👏 按钮并在下面自由地评论!我很乐意和你讨论 😃。
原文勘误与现代化说明
- 伪代码拼写:原文多处的
ExectionContext/GlobalExectionContext/FunctionExectionContext更正为ExecutionContext。 - 全局环境中 let/const 的位置(技术性错误):原文把全局的
a: <uninitialized>、b: <uninitialized>画在 Object 环境记录器里。按规范,全局环境同时有对象记录器(var/函数声明,挂到全局对象属性)与声明式记录器(let/const/class,不挂属性),样例伪代码已更正,并附globalThis.v/globalThis.l验证代码。 - 「全局执行上下文创建 window 对象」:更正为全局对象随 realm 先于代码执行创建;补充
globalThis(ES2020)、Nodeglobal、Workerself。 - this 绑定示例:补充严格模式(
undefined)、箭头函数(无自身 this)、class 方法体(默认严格)三种现代情况。 - 「创建/执行两阶段」标注为 ES5 时代心智模型,现行规范无「创建阶段」正式步骤。
- 执行阶段 let 赋 undefined 的误读:更正为「
let x;声明无初始化器时初始化为 undefined;TDZ 内访问抛 ReferenceError」。 arguments与形参联动的条件(非严格 + 简单形参)与箭头函数无arguments已补充。- 原文的执行栈示意图(Medium 图床
1*ACtBy8CIepTOSYcVwZ34Q.png)已 404,改为文字版栈演化图(内容与原图一致);补充new Error().stack观察调用栈的方法。 - 原文两段 Bit(bitsrc.io)产品推广与 2018 年延伸阅读链接已收敛至「原文归属」与「延伸阅读」;bitsrc.io 域名如今跳转 bit.dev,相关链接不再保证可达。
- ES6 规范链接由
ecma-international.org/ecma-262/6.0/更新为262.ecma-international.org/6.0/固定版本,并附现行规范章节。
图片来源与授权说明
本文无本地化图片:原文唯一的执行栈示意图(Medium 图床)已失效无法下载,正文以等价的文字版栈演化图替代。
原文归属
原文:Understanding Execution Context and Execution Stack in Javascript,Sukhjinder Arora,发表于 Bit 的 Bits and Pieces 博客(blog.bitsrc.io,现已迁至 bit.dev,原文章地址已不稳定,故不附具体链接)。译文:掘金翻译计划(2018)。当前语义以本文列出的规范和官方文档为准。
延伸阅读(原文末尾附带的关联阅读,均已验证可访问)
- 《JavaScript 深入之执行上下文栈》— 冴羽
- 《学习 JavaScript 之执行上下文》— kuitos
- 《JavaScript 深入之词法作用域和动态作用域》— 冴羽
- 《JavaScript 深入之作用域链》— 冴羽