十一:从规范看 JavaScript 执行上下文(下)

一、上篇回顾:创建阶段解决了什么

上篇我们讲到,每当 JavaScript 引擎进入一段可执行代码(全局代码、函数调用、eval),就会创建一个执行上下文。上下文在创建阶段完成了三件大事:

  1. 构建词法环境:登记 letconst、函数声明,并设置对外部词法环境的引用。
  2. 构建变量环境:登记 var 声明并初始化为 undefined
  3. 确定 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',然后上下文弹出销毁。

整个执行过程,本质上就是引擎在词法环境和变量环境中读取和写入绑定值的过程。每次读取变量,都遵循作用域链的查找规则;每次写入,都直接修改对应环境记录中的绑定。

三、标识符解析:当引擎遇到一个变量名时

执行阶段最常见的操作就是“取值”和“赋值”。规范中定义了标识符解析的严格算法。我们可以将其简化为以下步骤:

  1. 以当前执行上下文的词法环境为起点。
  2. 在该环境的环境记录中查找该标识符的绑定。
    • 如果找到,且其值不是 <uninitialized>,则返回该值(取值时)或修改该值(赋值时)。
    • 如果找到,但值为 <uninitialized>,则抛出 ReferenceError(触发暂时性死区)。
  3. 如果没找到,将当前环境替换为它的 Outer(外层词法环境),重复步骤 2。
  4. 如果一直回溯到全局词法环境仍未找到:
    • 取值时:在非严格模式下,返回 undefined(并可能隐式创建全局变量,这是反模式);在严格模式下,抛出 ReferenceError
    • 赋值时:在非严格模式下,隐式在全局对象上创建一个属性并赋值(即“意外全局变量”);在严格模式下,抛出 ReferenceError

这里有一个容易被忽视的细节:词法环境查找会优先于变量环境吗?

在大多数情况下,函数上下文的词法环境变量环境初始时指向同一个环境记录。只有当执行流进入块级作用域(如 {}for 循环体内)时,引擎才会创建一个新的词法环境,并将其 Outer 设置为原来的环境。此时,let/const 声明的变量存在于这个新的块级词法环境中,而 var 声明的变量仍然在原来的变量环境中。标识符解析总是先搜当前词法环境,再沿着 Outer 往外搜,这意味着块内的 let 变量会“遮蔽”外部的同名 var 变量。

四、闭包:执行上下文与词法环境的“遗产”

闭包可能是 JavaScript 中被讨论最多的概念,但从规范的角度看,它并不神秘。闭包的形成只需要两个条件:

  1. 函数嵌套:一个函数内部定义了另一个函数。
  2. 内部函数引用了外部函数的变量,并且内部函数在外部函数之外被执行(通过返回、作为参数传递、赋值给全局变量等方式)。

规范层面的解释是:每个函数对象在创建时,都有一个内部槽 [[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

内部发生了什么:

  1. createCounter() 被调用,创建了属于该次调用的词法环境,其中包含绑定 count = 0
  2. increment 函数在 createCounter 内部被创建,其 [[Environment]] 被设置为当前 createCounter 的词法环境。
  3. createCounter 执行结束,返回 increment 函数。此时 createCounter 的执行上下文出栈。
  4. 但由于全局变量 counter 持有了 increment 的引用,而 increment 又通过 [[Environment]] 引用了 createCounter 的词法环境,导致这个词法环境无法被回收。
  5. 每次调用 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 对执行上下文的影响:

  1. 进入 with 块时,引擎创建一个新的词法环境,其环境记录就是 obj 对象本身(通过对象环境记录实现)。
  2. 这个新词法环境的 Outer 指向 with 之前的正常词法环境。
  3. 执行 with 块内的代码时,标识符解析先在这个对象环境记录中查找属性,找不到再沿 Outer 查找。
  4. 离开 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() 执行完毕返回 innerouter 的上下文出栈。但 inner[[Environment]] 仍然指向 outer 的词法环境,所以 outer 的变量(如 outerLetouterVar)没有被销毁。调用 fn() 时仍能正常访问它们。

八、执行上下文全流程总结

整合上下篇,JavaScript 代码的执行流程可以归纳为:

  1. 编译阶段(规范中与创建阶段交织):扫描代码,识别所有声明(varletconstfunction)。
  2. 创建阶段
    • 创建词法环境(处理 let/const/function)和变量环境(处理 var)。
    • 确定 this
    • 绑定 Outer 引用,构建作用域链。
  3. 执行阶段
    • 逐行执行代码,触发标识符解析(沿作用域链查找)。
    • 赋值操作更新对应环境中的绑定。
    • 函数调用创建新的执行上下文,压入调用栈。
  4. 垃圾回收
    • 函数执行完毕后上下文出栈。
    • 如果不存在闭包引用,词法环境被回收。
    • 如果存在闭包,引用的词法环境被保留。

九、本篇小结

本篇作为执行上下文的下半部分,聚焦于代码真正运行时的机制:

  • 执行阶段:逐行执行代码,完成变量初始化和赋值,触发标识符解析。
  • 标识符解析算法:沿词法环境的 Outer 链逐级查找,遇到 <uninitialized> 时触发 TDZ 错误。
  • 闭包的本质:函数对象的 [[Environment]] 内部槽保留了定义时的词法环境引用。当内部函数在外部函数执行完毕后仍可达时,外部函数的词法环境无法被回收,形成闭包。闭包 = 函数 + 其定义时的词法环境。
  • with 语句:临时在作用域链前端插入对象环境,破坏编译优化,严格模式禁用。
  • eval 函数:动态执行代码字符串,非严格模式下可污染外层作用域,严格模式下隔离,均导致性能退化。

执行上下文是 JavaScript 运行时的核心骨架。理解它的创建与执行,你就拥有了定位任何变量查找问题、解释任何闭包行为、看穿任何提升现象的底层视角。

下一篇预告

下一篇——《JavaScript 异步编程》:从回调地狱到 Promise,从生成器到 async/await,我们将追溯 JavaScript 异步模型的演进,深入事件循环(Event Loop)、微任务与宏任务的调度机制,搞清楚 setTimeout 为什么不准时、Promise 为什么比 setTimeout 先执行。

前端,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容