一、上篇回顾:创建阶段解决了什么
上篇我们讲到,每当 JavaScript 引擎进入一段可执行代码(全局代码、函数调用、eval),就会创建一个执行上下文。上下文在创建阶段完成了三件大事:
- 构建词法环境:登记
let、const、函数声明,并设置对外部词法环境的引用。 - 构建变量环境:登记
var声明并初始化为undefined。 - 确定
this的值。
创建阶段结束的标志是:所有变量和函数的名字都已经在环境中“占位”,但大部分变量还处于未初始化或默认值状态。接下来进入执行阶段——代码逐行运行,赋值、运算、函数调用都在这个阶段发生。
本篇聚焦两个核心:执行阶段的具体行为,以及贯穿创建与执行两阶段的终极概念——闭包。同时,我们会彻底搞清楚 with 语句和 eval 如何破坏作用域链,以及为什么它们被强烈不推荐使用。
二、执行阶段:一行一行地“激活”变量
创建阶段为每个变量留下了“占位符”,但真正的值要在执行阶段逐行写入。以下面代码为例,我们逐步观察执行阶段发生了什么:
console.log(a); // ①
var a = 10;
console.log(a); // ②
let b = 20;
console.log(b); // ③
function foo() {
console.log('foo');
}
foo(); // ④
对应的执行上下文在创建阶段结束后的状态:
- 变量环境:
a: undefined - 词法环境:
b: <uninitialized>,foo: <function object>
执行阶段逐行推演:
行① console.log(a):在当前执行上下文的变量环境中查找 a,值为 undefined。输出 undefined。
行 var a = 10:这是一个 var 声明加赋值。变量 a 已经在变量环境中存在,所以只执行赋值操作,将 a 更新为 10。
行② console.log(a):再次查找,输出 10。
行 let b = 20:这是 let 声明加初始化加赋值。词法环境中的 b 此前处于 <uninitialized> 状态,执行到这一行时,引擎先将其初始化为 undefined,然后赋值为 20。从此 b 的暂时性死区结束。
行③ console.log(b):输出 20。
行 foo():创建一个新的函数执行上下文,压入调用栈。执行 foo 内部的代码,输出 'foo',然后上下文弹出销毁。
整个执行过程,本质上就是引擎在词法环境和变量环境中读取和写入绑定值的过程。每次读取变量,都遵循作用域链的查找规则;每次写入,都直接修改对应环境记录中的绑定。
三、标识符解析:当引擎遇到一个变量名时
执行阶段最常见的操作就是“取值”和“赋值”。规范中定义了标识符解析的严格算法。我们可以将其简化为以下步骤:
- 以当前执行上下文的词法环境为起点。
- 在该环境的环境记录中查找该标识符的绑定。
- 如果找到,且其值不是
<uninitialized>,则返回该值(取值时)或修改该值(赋值时)。 - 如果找到,但值为
<uninitialized>,则抛出ReferenceError(触发暂时性死区)。
- 如果找到,且其值不是
- 如果没找到,将当前环境替换为它的
Outer(外层词法环境),重复步骤 2。 - 如果一直回溯到全局词法环境仍未找到:
- 取值时:在非严格模式下,返回
undefined(并可能隐式创建全局变量,这是反模式);在严格模式下,抛出ReferenceError。 - 赋值时:在非严格模式下,隐式在全局对象上创建一个属性并赋值(即“意外全局变量”);在严格模式下,抛出
ReferenceError。
- 取值时:在非严格模式下,返回
这里有一个容易被忽视的细节:词法环境查找会优先于变量环境吗?
在大多数情况下,函数上下文的词法环境和变量环境初始时指向同一个环境记录。只有当执行流进入块级作用域(如 {} 或 for 循环体内)时,引擎才会创建一个新的词法环境,并将其 Outer 设置为原来的环境。此时,let/const 声明的变量存在于这个新的块级词法环境中,而 var 声明的变量仍然在原来的变量环境中。标识符解析总是先搜当前词法环境,再沿着 Outer 往外搜,这意味着块内的 let 变量会“遮蔽”外部的同名 var 变量。
四、闭包:执行上下文与词法环境的“遗产”
闭包可能是 JavaScript 中被讨论最多的概念,但从规范的角度看,它并不神秘。闭包的形成只需要两个条件:
- 函数嵌套:一个函数内部定义了另一个函数。
- 内部函数引用了外部函数的变量,并且内部函数在外部函数之外被执行(通过返回、作为参数传递、赋值给全局变量等方式)。
规范层面的解释是:每个函数对象在创建时,都有一个内部槽 [[Environment]],它保存着该函数定义时所在的词法环境引用。
当外部函数执行完毕,其执行上下文从调用栈弹出。按理说它的词法环境应该被销毁。但如果有内部函数引用了外部函数中的变量,并且这个内部函数被“传出”到了外部,那么外部函数的词法环境就无法被垃圾回收——因为内部函数的 [[Environment]] 仍然指向它。
这块存活下来的词法环境,加上内部函数自身,就构成了一个闭包。
经典示例
function createCounter() {
let count = 0; // 外部函数的变量
return function increment() { // 内部函数
count++; // 引用外部变量
return count;
};
}
let counter = createCounter(); // createCounter 执行完毕,正常来说 count 应该被销毁
console.log(counter()); // 1 —— 但 count 仍然存在,并且被正确累加
console.log(counter()); // 2
console.log(counter()); // 3
内部发生了什么:
createCounter()被调用,创建了属于该次调用的词法环境,其中包含绑定count = 0。increment函数在createCounter内部被创建,其[[Environment]]被设置为当前createCounter的词法环境。createCounter执行结束,返回increment函数。此时createCounter的执行上下文出栈。- 但由于全局变量
counter持有了increment的引用,而increment又通过[[Environment]]引用了createCounter的词法环境,导致这个词法环境无法被回收。 - 每次调用
counter()(即increment),它都能通过作用域链找到createCounter词法环境中的count,并正常读写。
闭包中容易踩的一个坑:循环中的闭包
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 0);
}
// 输出:4, 4, 4
原因:var 声明在变量环境中,三个回调函数共享同一个 i。当回调执行时,循环早已结束,i 已经是 4。
解决方案一:用 let 创建块级作用域(for 循环每次迭代都会创建一个新的词法环境,保存当次的 i 值)。
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 0);
}
// 输出:1, 2, 3
解决方案二:用闭包手动保存每次迭代的值。
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 0);
})(i);
}
// 输出:1, 2, 3
这个例子揭示了闭包的实质:一个函数加上它创建时所在的外部词法环境。每个 IIFE(立即执行函数)创建了独立的词法环境,保存了当次的 i 值。
五、with 语句:往作用域链前端插入一个对象
with 语句可以将一个对象临时推入作用域链的最前端,在 with 块内访问属性时,可以省略对象名。
let obj = { a: 1, b: 2 };
with (obj) {
console.log(a); // 1 —— 直接从 obj 的属性中查找
a = 3; // 修改 obj.a
c = 4; // 如果 obj 没有 c,则可能意外创建全局变量
}
with 对执行上下文的影响:
- 进入
with块时,引擎创建一个新的词法环境,其环境记录就是obj对象本身(通过对象环境记录实现)。 - 这个新词法环境的
Outer指向with之前的正常词法环境。 - 执行
with块内的代码时,标识符解析先在这个对象环境记录中查找属性,找不到再沿Outer查找。 - 离开
with块时,这个临时词法环境被移除。
为什么 with 被禁止(严格模式直接报错):
- 不可预测性:在
with块内对一个变量赋值,你无法确定是修改了传入对象的属性,还是不小心创建了全局变量。 - 性能灾难:由于对象属性在运行时可变,引擎无法在编译时优化
with块内的标识符解析。所有关于该块内变量查找的优化都必须放弃。
六、eval:往作用域链中注入新代码
eval 接受一个字符串,将其作为 JavaScript 代码在当前执行上下文中执行。
let x = 1;
eval('var y = 2; console.log(x + y);'); // 3
eval 对执行上下文的影响因模式而异:
- 非严格模式:
eval拥有修改所在作用域的能力。上面的var y = 2会污染当前函数的变量环境。 - 严格模式:
eval会创建自己独立的词法环境,内部声明的变量不会泄漏到外层。上面的var y在严格模式下只存在于eval的独立作用域中,外部无法访问。
为什么 eval 同样被强烈不推荐:
- 代码注入风险:如果
eval的参数来自用户输入,攻击者可以执行任意代码。 - 性能严重退化:引擎无法预知
eval会执行什么代码,因此必须放弃所有编译时优化。调用eval的函数会被强制进入“慢速路径”。 - 调试困难:动态生成的代码没有可读性,报错时堆栈信息难以追踪。
七、综合演示:闭包、TDZ 与作用域链的联动
下面这段代码集中展示了本篇的核心概念。请先在脑中推演输出结果,再与下方分析对照:
let global = 'global';
function outer() {
let outerLet = 'outer-let';
var outerVar = 'outer-var';
function inner() {
// 访问三个层级的变量
console.log(innerLet); // ①
console.log(outerVar); // ②
console.log(global); // ③
let innerLet = 'inner-let';
}
return inner;
}
let fn = outer();
fn();
逐行分析:
① console.log(innerLet):此时执行点在 let innerLet 声明之前。在 inner 的词法环境中,innerLet 的绑定已经在创建阶段建立了,但处于 <uninitialized> 状态。因此引擎检查到绑定存在但未初始化,直接抛出 ReferenceError: Cannot access 'innerLet' before initialization。代码不会执行到②③行。
如果我们将 let innerLet = 'inner-let'; 移到 console.log 之前:
function inner() {
let innerLet = 'inner-let';
console.log(innerLet); // 'inner-let' —— 当前词法环境找到
console.log(outerVar); // 'outer-var' —— 沿 Outer 到 outer 的变量环境找到
console.log(global); // 'global' —— 继续沿 Outer 到全局环境找到
}
作用域链结构如下:
inner 词法环境 { innerLet: 'inner-let' }
→ Outer → outer 词法环境 { outerLet: 'outer-let' } + outer 变量环境 { outerVar: 'outer-var' }
→ Outer → 全局词法环境 + 全局变量环境 { global: 'global' }
→ Outer → null
闭包发生在这里:outer() 执行完毕返回 inner,outer 的上下文出栈。但 inner 的 [[Environment]] 仍然指向 outer 的词法环境,所以 outer 的变量(如 outerLet、outerVar)没有被销毁。调用 fn() 时仍能正常访问它们。
八、执行上下文全流程总结
整合上下篇,JavaScript 代码的执行流程可以归纳为:
- 编译阶段(规范中与创建阶段交织):扫描代码,识别所有声明(
var、let、const、function)。 - 创建阶段:
- 创建词法环境(处理
let/const/function)和变量环境(处理var)。 - 确定
this。 - 绑定
Outer引用,构建作用域链。
- 创建词法环境(处理
- 执行阶段:
- 逐行执行代码,触发标识符解析(沿作用域链查找)。
- 赋值操作更新对应环境中的绑定。
- 函数调用创建新的执行上下文,压入调用栈。
- 垃圾回收:
- 函数执行完毕后上下文出栈。
- 如果不存在闭包引用,词法环境被回收。
- 如果存在闭包,引用的词法环境被保留。
九、本篇小结
本篇作为执行上下文的下半部分,聚焦于代码真正运行时的机制:
- 执行阶段:逐行执行代码,完成变量初始化和赋值,触发标识符解析。
- 标识符解析算法:沿词法环境的
Outer链逐级查找,遇到<uninitialized>时触发 TDZ 错误。 - 闭包的本质:函数对象的
[[Environment]]内部槽保留了定义时的词法环境引用。当内部函数在外部函数执行完毕后仍可达时,外部函数的词法环境无法被回收,形成闭包。闭包 = 函数 + 其定义时的词法环境。 with语句:临时在作用域链前端插入对象环境,破坏编译优化,严格模式禁用。eval函数:动态执行代码字符串,非严格模式下可污染外层作用域,严格模式下隔离,均导致性能退化。
执行上下文是 JavaScript 运行时的核心骨架。理解它的创建与执行,你就拥有了定位任何变量查找问题、解释任何闭包行为、看穿任何提升现象的底层视角。
下一篇预告
下一篇——《JavaScript 异步编程》:从回调地狱到 Promise,从生成器到 async/await,我们将追溯 JavaScript 异步模型的演进,深入事件循环(Event Loop)、微任务与宏任务的调度机制,搞清楚 setTimeout 为什么不准时、Promise 为什么比 setTimeout 先执行。
前端,每周更新。













暂无评论内容