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

一、为什么你要理解执行上下文

在此之前,我们学了原型链、数据类型、对象机制——这些都是 JavaScript 的“静态结构”。但代码终究是要运行的。变量怎么查找?函数调用时发生了什么?this 到底指向谁?letvar 的行为为什么不一样?“暂时性死区”究竟是什么?闭包又是怎么产生的?

所有这些问题,答案都指向同一个核心概念:执行上下文(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 上下文因为安全性和性能问题,几乎不应使用。

五、执行上下文的创建阶段(重点)

当一个执行上下文被创建时,引擎会经历两个阶段:创建阶段执行阶段。本篇聚焦于创建阶段,执行阶段留给下一篇。

在创建阶段,引擎会做三件至关重要的事:

  1. 创建词法环境(Lexical Environment)
  2. 创建变量环境(Variable Environment)
  3. 确定 this 绑定

这三件事做完,代码才正式开始逐行执行。

5.1 什么是词法环境

词法环境是 ECMAScript 规范中用来描述标识符(变量名、函数名)与值之间映射关系的结构。它由两部分组成:

  1. 环境记录(Environment Record):实际存储变量绑定(标识符 → 值)的地方。
  2. 对外部词法环境的引用(Outer Reference):指向外层词法环境的指针,构成作用域链

用伪代码表示:

LexicalEnvironment = {
  EnvironmentRecord: {
    // 标识符绑定:变量名 → 值
    // 例如:name → '张三', age → undefined (已声明但未初始化)
  },
  Outer:  // 指向外部词法环境的引用(用于作用域链查找)
}

在全局上下文中,Outernull(链的终点)。在函数上下文中,Outer 指向函数定义时所在的词法环境(即函数 [[Environment]] 内部槽)。

5.2 变量环境:var 的专属空间

在 ES6 之前,只有一种变量声明 var。ES6 引入了 letconst,它们的行为和 var 有本质区别。为了兼容,规范引入了变量环境词法环境的分离:

  • 词法环境:用于处理 letconstfunction 声明(块级作用域)。
  • 变量环境:用于处理 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,严格模式下顶层 thisundefined)。
  • 函数执行上下文:取决于函数的调用方式(见上一篇的四种调用模式)。简单回顾:
    • 普通函数调用:非严格模式下 this 指向全局对象,严格模式下为 undefined
    • 方法调用:this 指向调用该方法的对象。
    • 构造函数调用(new):this 指向新创建的对象。
    • call / apply / bind:显式指定的 this 值。
    • 箭头函数:不绑定自己的 this,它的 this 是捕获外层词法环境中的 this(词法作用域)。

在创建阶段,引擎根据调用方式解析出 this 的值,存储在执行上下文的 ThisBinding 字段中。函数体内的 this 引用就是从这个字段读取的。

六、词法环境与作用域链

当代码执行过程中遇到一个标识符(如变量名 name),引擎会按照以下顺序查找:

  1. 在当前执行上下文的词法环境中查找。
  2. 如果找不到,就沿着 Outer 引用进入外层词法环境继续查找。
  3. 重复直到全局词法环境。
  4. 如果全局环境中仍然找不到,在非严格模式下会隐式创建全局变量(这是反模式),严格模式下抛出 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();

创建阶段分析:

  1. 全局执行上下文创建
    • 变量环境:globalVar 绑定,初始化为 undefined
    • 词法环境:outer 函数声明绑定,初始化为函数对象。
    • this:指向全局对象。
  2. 执行阶段: 代码逐行执行,globalVar 被赋值为 '全局',遇到 outer() 调用,创建 outer 的执行上下文。
  3. outer 执行上下文创建
    • 变量环境:outerVar 绑定,初始化为 undefined
    • 词法环境:inner 函数声明绑定,初始化为函数对象;Outer 指向全局词法环境。
    • this:普通函数调用,指向全局对象(非严格模式)。
  4. inner 执行上下文创建
    • 变量环境:innerVar 绑定,初始化为 undefined
    • 词法环境:Outer 指向 outer 的词法环境。
    • this:普通函数调用,指向全局对象。

执行 console.log(innerVar) 时,在当前词法环境中找到。执行 console.log(outerVar) 时,当前环境没有,沿 Outerouter 的词法环境(实际在变量环境中,但查找时会搜索变量环境),找到。执行 console.log(globalVar) 时,继续沿作用域链找到全局变量环境。这就是作用域链查找的全过程。

八、本篇小结

这一篇我们从规范层面拆解了执行上下文的创建过程:

  • 执行上下文是代码运行时的环境抽象,包含词法环境、变量环境和 this 绑定。
  • 调用栈管理上下文的压入和弹出,遵循后进先出。
  • 创建阶段处理变量声明:
    • var 在变量环境中绑定并初始化为 undefined(提升)。
    • 函数声明在词法环境中绑定并初始化为函数对象(提升)。
    • let/const 在词法环境中绑定但不初始化(暂时性死区)。
  • 词法环境由环境记录和 Outer 引用组成,Outer 链构成作用域链。
  • 变量环境专门处理 var 声明,使得 var 的行为与 let/const 区分开。
  • this 在创建阶段确定,取决于调用方式。

这半部分集中在“创建阶段”。下一篇,我们将进入“执行阶段”——代码一行行运行时发生了什么,作用域链如何动态工作,闭包是如何在底层形成的,以及 witheval 对作用域链的影响。

下一篇预告

下一篇——《从规范看 JavaScript 执行上下文(下)》:我们将详细拆解执行阶段的过程。变量赋值、标识符解析、作用域链的动态查找、闭包的形成机制——为什么函数可以“记住”它出生时的环境?这些谜题的答案,都藏在执行阶段和 [[Environment]] 内部槽中。

前端,每周更新。

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

请登录后发表评论

    暂无评论内容