一、为什么你要理解执行上下文
在此之前,我们学了原型链、数据类型、对象机制——这些都是 JavaScript 的“静态结构”。但代码终究是要运行的。变量怎么查找?函数调用时发生了什么?this 到底指向谁?let 和 var 的行为为什么不一样?“暂时性死区”究竟是什么?闭包又是怎么产生的?
所有这些问题,答案都指向同一个核心概念:执行上下文(Execution Context)。
执行上下文是 ECMAScript 规范中定义的一种抽象机制,用来描述 JavaScript 代码运行时的环境。每一段正在运行的代码,都处于某个执行上下文中。理解它,你就掌握了 JavaScript 运行时的底层模型。
这一篇,我们将直接从 ECMAScript 规范出发,拆解执行上下文的内部结构。内容会非常硬核,但这是区分“会用 JavaScript”和“真正理解 JavaScript”的分水岭。
二、执行上下文是什么
根据 ECMAScript 规范,执行上下文是一个包含代码运行时所需的所有信息的内部对象。当 JavaScript 引擎执行一段代码时,它会创建对应的执行上下文,用来跟踪:
- 当前正在执行哪段代码
- 有哪些变量、函数可用(词法环境)
this的值是什么- 如果当前代码调用了另一个函数,怎么回到当前位置(调用栈)
规范中将执行上下文定义为一个包含以下组件的结构(简化):
ExecutionContext = {
LexicalEnvironment: ..., // 词法环境
VariableEnvironment: ..., // 变量环境
ThisBinding: ..., // this 绑定
// 以及其他内部字段
}
每次函数调用、全局代码执行、eval 执行,都会创建一个新的执行上下文。
三、调用栈:执行上下文的“来去记录”
JavaScript 引擎使用调用栈(Call Stack)来管理多个执行上下文。它遵循栈的“后进先出”规则:
- 当引擎开始执行一段代码(比如脚本加载),会创建一个全局执行上下文,压入栈底。
- 当遇到函数调用,就创建一个新的函数执行上下文,压入栈顶。
- 函数执行完毕,其上下文从栈顶弹出,引擎回到上一个上下文继续执行。
- 所有代码执行完毕,全局上下文出栈,程序结束。
用一个例子来可视化:
function first() {
console.log('first 开始');
second(); // 调用 second
console.log('first 结束');
}
function second() {
console.log('second 开始');
third(); // 调用 third
console.log('second 结束');
}
function third() {
console.log('third 执行中');
}
first();
console.log('全局结束');
调用栈的变化过程:
1. 初始: 栈空
2. 脚本加载: [全局上下文]
3. 调用 first(): [全局上下文] → [first 上下文]
4. 调用 second(): [全局上下文] → [first 上下文] → [second 上下文]
5. 调用 third(): [全局上下文] → [first 上下文] → [second 上下文] → [third 上下文]
6. third 结束: [全局上下文] → [first 上下文] → [second 上下文]
7. second 结束: [全局上下文] → [first 上下文]
8. first 结束: [全局上下文]
9. 全局代码结束: 栈空
如果调用栈过深(比如无限递归),栈空间会被耗尽,浏览器会抛出“Maximum call stack size exceeded”错误。
四、执行上下文的三种类型
ECMAScript 规范定义了三种执行上下文:
| 类型 | 创建时机 | 特点 |
|---|---|---|
| 全局执行上下文 | 脚本开始执行时创建(有且仅有一个) | 创建全局对象(浏览器中为 window),this 指向全局对象 |
| 函数执行上下文 | 每次函数调用时创建 | 每个函数调用都会创建一个新的上下文;this 的值取决于调用方式 |
| eval 执行上下文 | eval() 函数执行时创建 |
严格模式下 eval 有自己独立的词法环境;非严格模式下可能污染外部作用域(极少使用) |
日常开发中,我们主要和全局上下文、函数上下文打交道。eval 上下文因为安全性和性能问题,几乎不应使用。
五、执行上下文的创建阶段(重点)
当一个执行上下文被创建时,引擎会经历两个阶段:创建阶段和执行阶段。本篇聚焦于创建阶段,执行阶段留给下一篇。
在创建阶段,引擎会做三件至关重要的事:
- 创建词法环境(Lexical Environment)
- 创建变量环境(Variable Environment)
- 确定
this绑定
这三件事做完,代码才正式开始逐行执行。
5.1 什么是词法环境
词法环境是 ECMAScript 规范中用来描述标识符(变量名、函数名)与值之间映射关系的结构。它由两部分组成:
- 环境记录(Environment Record):实际存储变量绑定(标识符 → 值)的地方。
- 对外部词法环境的引用(Outer Reference):指向外层词法环境的指针,构成作用域链。
用伪代码表示:
LexicalEnvironment = {
EnvironmentRecord: {
// 标识符绑定:变量名 → 值
// 例如:name → '张三', age → undefined (已声明但未初始化)
},
Outer: // 指向外部词法环境的引用(用于作用域链查找)
}
在全局上下文中,Outer 为 null(链的终点)。在函数上下文中,Outer 指向函数定义时所在的词法环境(即函数 [[Environment]] 内部槽)。
5.2 变量环境:var 的专属空间
在 ES6 之前,只有一种变量声明 var。ES6 引入了 let 和 const,它们的行为和 var 有本质区别。为了兼容,规范引入了变量环境与词法环境的分离:
- 词法环境:用于处理
let、const、function声明(块级作用域)。 - 变量环境:用于处理
var声明(函数级作用域)。
在创建阶段,引擎会遍历当前作用域内的所有声明,将它们分别放入词法环境或变量环境:
- 遇到
var声明 → 在变量环境中创建绑定,并初始化为undefined(这就是“变量提升”的来源)。 - 遇到
let/const声明 → 在词法环境中创建绑定,但不初始化(导致“暂时性死区”)。 - 遇到函数声明 → 在词法环境中创建绑定,并立即初始化为函数对象(函数提升,可以在声明前调用)。
5.3 创建阶段的具体步骤(规范伪代码)
以下是根据规范简化后的创建阶段流程:
// 当进入一个函数执行上下文时
function FunctionDeclarationInstantiation(func, argumentsList) {
// 1. 创建新的词法环境和变量环境(它们初始时指向同一个环境)
let localEnv = NewDeclarativeEnvironment(func.[[Environment]]);
let varEnv = localEnv; // 变量环境初始和词法环境相同
// 2. 处理函数声明:在词法环境中绑定函数名,立即初始化为函数对象
for (let fn of func.FunctionDeclarations) {
CreateImmutableBinding(localEnv, fn.name, fn);
}
// 3. 处理形参:在词法环境中绑定参数名,初始化为传入的实参值
for (let param of func.FormalParameters) {
CreateMutableBinding(localEnv, param.name, arguments[i]);
}
// 4. 处理 var 声明:在变量环境中绑定,初始化为 undefined
for (let varDecl of func.VarDeclarations) {
CreateMutableBinding(varEnv, varDecl.name, undefined);
}
// 5. 处理 let/const 声明:在词法环境中绑定,但不初始化!
for (let lexDecl of func.LexicalDeclarations) {
CreateMutableBinding(localEnv, lexDecl.name, UNINITIALIZED);
// 注意:let/const 绑定的值在此时是“未初始化”状态
// 直到执行到声明语句时,才会被正式初始化
}
// 6. 确定 this 绑定(见 5.5 节)
let thisValue = ResolveThisBinding(func, ...);
return { localEnv, varEnv, thisValue };
}
这个流程精确解释了:
var为什么会被提升并初始化为undefined。- 函数声明为什么可以在定义之前调用。
let/const为什么存在暂时性死区——因为它们在创建阶段只创建了绑定,但没有初始化。
5.4 let/const 与暂时性死区(TDZ)
暂时性死区是 ES6 引入的概念,指的是:从进入作用域(块或函数)开始,到 let/const 声明语句被执行之前,这段时间内该标识符不能被访问,否则会抛出 ReferenceError。
规范层面的解释:在创建阶段,let/const 的绑定被创建,但标记为“未初始化”状态。引擎在读取变量时,如果发现绑定存在但处于未初始化状态,就抛出错误。直到执行到声明语句(如 let x = 10;),绑定才被初始化为具体值,之后才能正常访问。
function demo() {
console.log(a); // undefined —— var 提升并初始化为 undefined
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
// let 存在但未初始化 —— 暂时性死区
var a = 1;
let b = 2;
console.log(a); // 1
console.log(b); // 2
}
demo();
即使在块级作用域中,TDZ 同样适用:
{
// TDZ 开始
// console.log(x); // ReferenceError
let x = 5; // TDZ 结束
console.log(x); // 5
}
注意:typeof 操作符在 TDZ 内也不是安全的——对未初始化的 let/const 变量使用 typeof 同样抛出 ReferenceError。这与对未声明的变量使用 typeof(返回 'undefined')完全不同。
5.5 this 绑定的确定
创建阶段的第三个关键步骤是确定 this 的值。不同类型的执行上下文,this 的确定规则不同:
- 全局执行上下文:
this指向全局对象(浏览器中为window,Node.js 中为global,严格模式下顶层this为undefined)。 - 函数执行上下文:取决于函数的调用方式(见上一篇的四种调用模式)。简单回顾:
- 普通函数调用:非严格模式下
this指向全局对象,严格模式下为undefined。 - 方法调用:
this指向调用该方法的对象。 - 构造函数调用(
new):this指向新创建的对象。 call/apply/bind:显式指定的this值。- 箭头函数:不绑定自己的
this,它的this是捕获外层词法环境中的this(词法作用域)。
- 普通函数调用:非严格模式下
在创建阶段,引擎根据调用方式解析出 this 的值,存储在执行上下文的 ThisBinding 字段中。函数体内的 this 引用就是从这个字段读取的。
六、词法环境与作用域链
当代码执行过程中遇到一个标识符(如变量名 name),引擎会按照以下顺序查找:
- 在当前执行上下文的词法环境中查找。
- 如果找不到,就沿着
Outer引用进入外层词法环境继续查找。 - 重复直到全局词法环境。
- 如果全局环境中仍然找不到,在非严格模式下会隐式创建全局变量(这是反模式),严格模式下抛出
ReferenceError。
这个由 Outer 引用串联起来的链条,就是作用域链。注意:作用域链的构建是在函数定义时就确定了的,而不是在调用时。函数对象内部有一个 [[Environment]] 槽,保存了它定义时所在的词法环境。当函数被调用时,新创建的词法环境的 Outer 引用就是从这个 [[Environment]] 槽获取的。
这直接导致了闭包的形成——函数可以访问定义时所在作用域中的变量,即使那个作用域已经“销毁”了(更准确地说,是因为闭包引用了外部变量,导致外部词法环境无法被垃圾回收)。
七、完整演示:执行上下文创建全过程
下面通过一段代码,逐步追踪执行上下文的创建和变量的初始化:
var globalVar = '全局';
function outer() {
var outerVar = '外部';
function inner() {
var innerVar = '内部';
console.log(innerVar); // ①
console.log(outerVar); // ②
console.log(globalVar); // ③
}
inner();
}
outer();
创建阶段分析:
- 全局执行上下文创建:
- 变量环境:
globalVar绑定,初始化为undefined。 - 词法环境:
outer函数声明绑定,初始化为函数对象。 this:指向全局对象。
- 变量环境:
- 执行阶段: 代码逐行执行,
globalVar被赋值为'全局',遇到outer()调用,创建outer的执行上下文。 outer执行上下文创建:- 变量环境:
outerVar绑定,初始化为undefined。 - 词法环境:
inner函数声明绑定,初始化为函数对象;Outer指向全局词法环境。 this:普通函数调用,指向全局对象(非严格模式)。
- 变量环境:
inner执行上下文创建:- 变量环境:
innerVar绑定,初始化为undefined。 - 词法环境:
Outer指向outer的词法环境。 this:普通函数调用,指向全局对象。
- 变量环境:
执行 console.log(innerVar) 时,在当前词法环境中找到。执行 console.log(outerVar) 时,当前环境没有,沿 Outer 到 outer 的词法环境(实际在变量环境中,但查找时会搜索变量环境),找到。执行 console.log(globalVar) 时,继续沿作用域链找到全局变量环境。这就是作用域链查找的全过程。
八、本篇小结
这一篇我们从规范层面拆解了执行上下文的创建过程:
- 执行上下文是代码运行时的环境抽象,包含词法环境、变量环境和
this绑定。 - 调用栈管理上下文的压入和弹出,遵循后进先出。
- 创建阶段处理变量声明:
var在变量环境中绑定并初始化为undefined(提升)。- 函数声明在词法环境中绑定并初始化为函数对象(提升)。
let/const在词法环境中绑定但不初始化(暂时性死区)。
- 词法环境由环境记录和
Outer引用组成,Outer链构成作用域链。 - 变量环境专门处理
var声明,使得var的行为与let/const区分开。 this在创建阶段确定,取决于调用方式。
这半部分集中在“创建阶段”。下一篇,我们将进入“执行阶段”——代码一行行运行时发生了什么,作用域链如何动态工作,闭包是如何在底层形成的,以及 with 和 eval 对作用域链的影响。
下一篇预告
下一篇——《从规范看 JavaScript 执行上下文(下)》:我们将详细拆解执行阶段的过程。变量赋值、标识符解析、作用域链的动态查找、闭包的形成机制——为什么函数可以“记住”它出生时的环境?这些谜题的答案,都藏在执行阶段和 [[Environment]] 内部槽中。
前端,每周更新。













暂无评论内容