JavaScript 里的词法环境(Lexical Environment)与变量环境(Variable Environment)的区别

各位同学,大家下午好!

今天,我们将深入探讨 JavaScript 中两个核心但常常被混淆的概念:词法环境(Lexical Environment)与变量环境(Variable Environment)。理解它们之间的区别和联系,是掌握 JavaScript 作用域、变量生命周期以及闭包等高级特性的基石。作为一名编程专家,我希望通过这次讲座,能够彻底厘清这两个概念,并帮助大家构建一个更坚实的 JavaScript 知识体系。

我们将从宏观的执行上下文(Execution Context)开始,逐步解构其内部的运行机制,最终聚焦到词法环境和变量环境的具体作用及其动态变化。请大家准备好,让我们一起踏上这段探索之旅。

一、宏观视角:执行上下文(Execution Context)

在 JavaScript 代码执行的任何时刻,它都运行在一个特定的“环境”中,这个环境就是执行上下文(Execution Context)。执行上下文是 JavaScript 引擎用来管理代码执行流程、变量存储和函数调用的核心机制。每当 JavaScript 引擎准备执行一段代码时(无论是全局代码、函数代码还是 eval 代码),它都会创建一个新的执行上下文。

一个执行上下文在逻辑上包含三个主要部分:

  1. 变量环境(Variable Environment):我们今天的重点之一。它是一个特殊的词法环境,用于存储当前上下文中的变量(特别是 var 声明的变量)和函数声明。
  2. 词法环境(Lexical Environment):今天的另一个重点。它是一个抽象概念,用于定义标识符(变量名、函数名)到特定变量或函数的映射关系。它在执行过程中可以动态变化。
  3. this 绑定(this Binding):确定当前执行上下文中 this 关键字的值。

我们今天的讨论将主要围绕前两个组件展开。

JavaScript 引擎在创建执行上下文时,会经历两个阶段:

  1. 创建阶段(Creation Phase)
    • 确定 this 的值。
    • 创建词法环境组件。
    • 创建变量环境组件。
    • (重要)处理函数声明和 var 变量声明,将它们添加到变量环境(并因此也添加到词法环境)。letconst 变量在此阶段也会被处理,但不会被初始化,并被放置在“暂时性死区”(Temporal Dead Zone, TDZ)中,直到它们被实际声明的代码行执行。
  2. 执行阶段(Execution Phase)
    • 逐行执行代码,对变量进行赋值,并执行函数调用。

理解这两个阶段对于我们后续区分词法环境和变量环境至关重要。

二、深入剖析:词法环境(Lexical Environment)

词法环境是 JavaScript 规范中定义的一种抽象数据结构,它用于存储标识符和它们所绑定的变量/函数的关联关系。它是一个核心概念,决定了 JavaScript 中变量和函数的可访问性,也就是我们常说的“作用域”。

什么是“词法”?

“词法”一词指的是代码的物理结构,即在代码被写下和编译(或解析)时,变量和函数在代码中的位置。一个函数的作用域在它被定义时就确定了,而不是在它被调用时。这是 JavaScript 作用域链的基础。

2.1 词法环境的结构

每个词法环境都包含两个主要组件:

  1. 环境记录器(Environment Record)
    • 这是一个实际存储标识符绑定(即变量名与值的映射)的地方。
    • 它可以是以下两种类型之一:
      • 声明式环境记录器(Declarative Environment Record):用于存储函数声明、变量声明(var, let, const)以及 catch 块中的变量。它直接将标识符映射到它们的值。
      • 对象环境记录器(Object Environment Record):主要用于全局上下文。它将标识符绑定到指定的对象属性上。例如,在全局上下文中,var 声明的变量和函数声明都会成为全局对象(浏览器中是 window,Node.js 中是 global)的属性。with 语句也会创建对象环境记录器。
  2. 外部词法环境引用(Outer Lexical Environment Reference)
    • 这是一个指向其父级(即包含它的)词法环境的引用。
    • 这个引用是构建作用域链的关键。当 JavaScript 引擎需要查找一个变量时,它会首先在当前的词法环境的环境记录器中查找。如果找不到,它就会沿着 Outer Lexical Environment Reference 向上查找,直到找到该变量或者到达全局词法环境(如果仍未找到,则抛出 ReferenceError)。

2.2 词法环境的创建与作用域链

每当:

  • 全局代码执行时。
  • 一个函数被调用时。
  • 一个 letconst 声明的块级作用域被进入时。
  • with 语句或 catch 块被执行时。

都会创建一个新的词法环境。

让我们通过一个简单的例子来理解词法环境及其外部引用:

var globalVar = "我是全局变量";

function outerFunction() {
    var outerVar = "我是外部函数变量";

    function innerFunction() {
        var innerVar = "我是内部函数变量";
        console.log(innerVar);  // 查找 innerVar
        console.log(outerVar);  // 查找 outerVar
        console.log(globalVar); // 查找 globalVar
    }

    innerFunction();
}

outerFunction();

innerFunction 被调用时:

  1. innerFunction 的词法环境 被创建。
    • 其环境记录器包含 innerVar
    • Outer Lexical Environment Reference 指向 outerFunction 的词法环境
  2. console.log(innerVar) 执行时,引擎在 innerFunction 的环境记录器中找到 innerVar
  3. console.log(outerVar) 执行时,引擎在 innerFunction 的环境记录器中找不到 outerVar。它会沿着 Outer Lexical Environment Reference 向上,进入 outerFunction 的词法环境。
  4. outerFunction 的环境记录器中找到 outerVar
  5. console.log(globalVar) 执行时,引擎在 innerFunctionouterFunction 的环境记录器中都找不到 globalVar。它会继续沿着 Outer Lexical Environment Reference 向上,进入 全局词法环境
  6. 在全局词法环境的环境记录器中找到 globalVar

这个查找过程就是作用域链(Scope Chain)。词法环境的 Outer Lexical Environment Reference 构成了这个链条。

2.3 闭包与词法环境

词法环境是理解闭包(Closure)的关键。闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。

function makeCounter() {
    let count = 0; // count 存在于 makeCounter 的词法环境的环境记录器中

    return function() { // 这个匿名函数
        count++;
        console.log(count);
    };
}

const counter1 = makeCounter();
counter1(); // 1
counter1(); // 2

const counter2 = makeCounter(); // 再次调用 makeCounter 会创建新的词法环境
counter2(); // 1

makeCounter 被调用时,它创建了一个新的词法环境,其中包含 count 变量。它返回的匿名函数在创建时,其 Outer Lexical Environment Reference 就被设置为指向这个 makeCounter 的词法环境。

即使 makeCounter 执行完毕,其词法环境通常会被销毁,但由于返回的匿名函数仍然持有对它的引用(通过 Outer Lexical Environment Reference),这个词法环境就不会被垃圾回收,count 变量也得以保留。这就是闭包的魔力。

2.4 块级作用域与词法环境

ES6 引入了 letconst,它们支持块级作用域。这意味着 {} 括号内的代码块也会创建新的词法环境。

function blockScopeExample() {
    var x = 10;
    let y = 20;

    if (true) {
        var x = 30; // 这里的 x 还是指向函数作用域的 x
        let y = 40; // 这里的 y 是一个新的块级作用域变量
        const z = 50;

        console.log("Inside block:");
        console.log("x:", x); // 30
        console.log("y:", y); // 40
        console.log("z:", z); // 50
    }

    console.log("Outside block:");
    console.log("x:", x); // 30 (var 的副作用)
    console.log("y:", y); // 20 (let 的块级作用域特性)
    // console.log("z:", z); // ReferenceError: z is not defined
}

blockScopeExample();

在这个例子中:

  1. blockScopeExample 函数执行时,它会创建一个函数词法环境。
    • 这个环境的环境记录器中包含 x (初始值 10) 和 y (初始值 20)。
  2. 当进入 if (true) 块时,JavaScript 引擎会为这个块创建一个新的词法环境
    • 这个新的词法环境的 Outer Lexical Environment Reference 指向 blockScopeExample 的词法环境。
    • 这个新环境的环境记录器中包含块级 y (初始值 40) 和 z (初始值 50)。
    • var x = 30 并没有在这个块级词法环境中创建新的绑定,而是修改了 blockScopeExample 词法环境中的 x。这是 var 的一个重要特性:它没有块级作用域。
  3. if 块结束时,其对应的词法环境会被销毁(如果不再有引用)。

这清晰地展示了 let/const 如何通过创建新的词法环境来实现块级作用域。

三、深入剖析:变量环境(Variable Environment)

现在,让我们把焦点转向变量环境。变量环境是执行上下文的一个组件,它是一个特殊的词法环境。

更准确地说,变量环境是执行上下文的词法环境在创建阶段的快照。它包含了在该执行上下文中通过 var 关键字声明的变量和函数声明。这些声明在创建阶段就会被处理,并添加到变量环境的环境记录器中,无论它们在代码中的物理位置如何(这就是 var 和函数声明的“提升”现象)。

3.1 变量环境的核心特性

  1. 在创建阶段被设置:当一个执行上下文被创建时,它的变量环境就会被初始化。所有 var 声明的变量和函数声明都会被添加到这个环境记录器中。
  2. 包含 var 和函数声明:这是它与普通词法环境在行为上最主要的区别。
  3. 不处理 letconstletconst 声明的变量不会被添加到变量环境。它们会在其各自的块级词法环境中进行管理。
  4. 通常保持不变:一旦变量环境在执行上下文的创建阶段被建立,它在整个执行上下文的生命周期内通常是不会改变的。而执行上下文的当前词法环境 (LexicalEnvironment 属性) 可以在执行阶段动态地被更新,以反映进入和退出块级作用域的情况。

3.2 变量环境与提升(Hoisting)

变量环境是解释 var 和函数声明提升现象的根本。

function hoistingExample() {
    console.log(a); // undefined
    var a = 10;
    console.log(a); // 10

    console.log(b); // ReferenceError: b is not defined (TDZ)
    let b = 20;

    foo(); // "Hello from foo!"
    function foo() {
        console.log("Hello from foo!");
    }

    bar(); // TypeError: bar is not a function (bar is hoisted but undefined)
    var bar = function() {
        console.log("Hello from bar!");
    };
}

hoistingExample();

hoistingExample 函数的执行上下文创建时:

  1. 变量环境 被创建。
  2. var a 被扫描到,a 被添加到变量环境的环境记录器中,并初始化为 undefined
  3. function foo() 被扫描到,foo 被添加到变量环境的环境记录器中,并直接绑定到函数定义。
  4. var bar 被扫描到,bar 被添加到变量环境的环境记录器中,并初始化为 undefined
  5. let b 被扫描到,b 也被“提升”,但它不被添加到变量环境,而是被放置在当前词法环境(函数词法环境)的“暂时性死区”(TDZ)中。

在执行阶段:

  • console.log(a):此时 a 已在变量环境中存在且为 undefined,所以输出 undefined
  • a = 10a 被赋值为 10
  • console.log(a):输出 10
  • console.log(b):此时 b 仍在 TDZ 中,访问会报错 ReferenceError
  • let b = 20b 被初始化并赋值。
  • foo()foo 已经在变量环境中完全初始化,可以正常调用。
  • bar()bar 此时在变量环境中为 undefined,尝试调用 undefined 会导致 TypeError
  • var bar = function() { ... }bar 被赋值为函数表达式。

这个例子清晰地展示了变量环境如何在执行上下文的创建阶段处理 var 和函数声明,并解释了它们的提升行为。

四、核心差异与动态演变:Lexical Environment vs Variable Environment

现在我们来到了最关键的部分:它们之间的区别与联系。

表格:词法环境(Lexical Environment)与变量环境(Variable Environment)对比

特性 词法环境(Lexical Environment) 变量环境(Variable Environment)
定义 抽象概念,用于定义标识符到变量/函数的映射,管理作用域。 执行上下文的特定组件,是一个特殊的词法环境,在创建阶段被初始化。
包含内容 所有类型的声明(var, let, const, function)以及参数。 仅包含 var 声明的变量和函数声明。
动态性 在执行上下文的生命周期内,其 Environment RecordOuter Lexical Environment Reference 会根据代码块的进入和退出而动态变化。 一旦在执行上下文的创建阶段被设置,在其整个生命周期内通常保持不变。
主要用途 管理整个作用域链,决定变量查找规则,支持闭包和块级作用域。 主要用于在创建阶段处理 var 变量和函数声明的提升。
与EC的关系 执行上下文的 LexicalEnvironment 属性指向当前活跃的词法环境。 执行上下文的 VariableEnvironment 属性指向创建阶段的词法环境。
let/const let/const 声明的变量会创建新的词法环境,或在现有词法环境中绑定。 不包含 let/const 声明的变量。

4.1 初始状态:两者通常相同

在执行上下文的创建阶段,当没有遇到任何块级作用域(由 letconst 引起)时,执行上下文的 LexicalEnvironment 属性和 VariableEnvironment 属性通常会指向同一个词法环境对象。

// 假设这是函数执行上下文的伪代码表示
ExecutionContext = {
    LexicalEnvironment: <FunctionLexicalEnvironment>,
    VariableEnvironment: <FunctionLexicalEnvironment>, // 初始时指向同一个对象
    ThisBinding: ...
}

FunctionLexicalEnvironment = {
    EnvironmentRecord: {
        // var 变量和函数声明被添加到这里
        // 假设 var x = 10; function foo() {}
        x: undefined, // for var
        foo: <func obj>
    },
    OuterLexicalEnvironmentReference: <ParentLexicalEnvironment>
}

4.2 动态演变:当 LexicalEnvironment 偏离 VariableEnvironment

这是理解两者区别的关键点。VariableEnvironment 一旦在创建阶段被设置,就固定了。但 LexicalEnvironment动态的。当 JavaScript 引擎在执行阶段遇到 letconst 声明的块时,它会创建一个新的词法环境,并更新执行上下文的 LexicalEnvironment 属性来指向这个新的词法环境。

让我们通过一个详细的例子来模拟这个过程:

function dynamicEnvExample() {
    var a = 10;
    let b = 20;

    console.log("Before block: a =", a, "b =", b); // a=10, b=20

    if (true) {
        var c = 30; // var 声明
        let d = 40; // let 声明

        console.log("Inside block: a =", a, "b =", b, "c =", c, "d =", d); // a=10, b=20, c=30, d=40
    }

    console.log("After block: a =", a, "b =", b, "c =", c); // a=10, b=20, c=30
    // console.log("After block: d =", d); // ReferenceError: d is not defined
}

dynamicEnvExample();

执行流程分析:

1. dynamicEnvExample() 函数被调用,创建一个新的函数执行上下文。

  • 创建阶段:

    • this 绑定确定。
    • 变量环境(VariableEnvironment) 被创建并初始化:
      • 它是一个词法环境对象,我们称之为 FuncVE
      • FuncVE.EnvironmentRecord 包含:
        • a: undefined (来自 var a = 10;)
        • c: undefined (来自 var c = 30;注意,var 提升到函数作用域)
      • FuncVE.OuterLexicalEnvironmentReference 指向全局词法环境。
    • 词法环境(LexicalEnvironment) 同样被创建并初始化,此时它与 VariableEnvironment 指向同一个对象 FuncVE
      • FuncVE.EnvironmentRecord 还会处理 let b = 20;b 被添加到 FuncVE.EnvironmentRecord,但处于 TDZ。
    • ExecutionContext 看起来像这样:
      ExecutionContext = {
          LexicalEnvironment: FuncVE,
          VariableEnvironment: FuncVE,
          ThisBinding: ...
      }
      FuncVE = {
          EnvironmentRecord: { a: undefined, c: undefined, b: <TDZ> },
          OuterLexicalEnvironmentReference: GlobalLE
      }
  • 执行阶段:

    • var a = 10;FuncVE.EnvironmentRecord.aundefined 更新为 10

    • let b = 20;b 离开 TDZ,FuncVE.EnvironmentRecord.b 更新为 20

    • console.log("Before block: a =", a, "b =", b);

      • 查找 a:在 ExecutionContext.LexicalEnvironment (即 FuncVE) 中找到 a: 10
      • 查找 b:在 ExecutionContext.LexicalEnvironment (即 FuncVE) 中找到 b: 20
      • 输出 a=10, b=20
    • 进入 if (true) 块:

      • JavaScript 引擎检测到块级作用域(因为里面有 let d),创建一个新的词法环境,我们称之为 BlockLE
      • BlockLE.EnvironmentRecord 包含:d: <TDZ> (来自 let d = 40;)
      • BlockLE.OuterLexicalEnvironmentReference 指向当前的 ExecutionContext.LexicalEnvironment (即 FuncVE)。
      • 最关键的一步:ExecutionContext.LexicalEnvironment 现在更新为 BlockLE
      • 注意:ExecutionContext.VariableEnvironment 仍然指向 FuncVE,它没有改变!
        ExecutionContext = {
        LexicalEnvironment: BlockLE, // 改变了!
        VariableEnvironment: FuncVE, // 保持不变
        ThisBinding: ...
        }
        BlockLE = {
        EnvironmentRecord: { d: <TDZ> },
        OuterLexicalEnvironmentReference: FuncVE
        }
    • var c = 30;

      • var 没有块级作用域。它会在当前的 LexicalEnvironment (即 BlockLE) 中查找 c。找不到时,会沿着 OuterLexicalEnvironmentReference 向上,在 FuncVE 中找到 c
      • FuncVE.EnvironmentRecord.cundefined 更新为 30
    • let d = 40;

      • d 离开 TDZ,BlockLE.EnvironmentRecord.d 更新为 40
    • console.log("Inside block: a =", a, "b =", b, "c =", c, "d =", d);

      • 查找 aBlockLE -> FuncVE 找到 a: 10
      • 查找 bBlockLE -> FuncVE 找到 b: 20
      • 查找 cBlockLE -> FuncVE 找到 c: 30
      • 查找 d:在 BlockLE 中找到 d: 40
      • 输出 a=10, b=20, c=30, d=40
    • 退出 if (true) 块:

      • BlockLE 不再活跃。
      • ExecutionContext.LexicalEnvironment 恢复到进入块之前的状态,重新指向 FuncVE
        ExecutionContext = {
        LexicalEnvironment: FuncVE, // 恢复了!
        VariableEnvironment: FuncVE, // 依然不变
        ThisBinding: ...
        }
    • console.log("After block: a =", a, "b =", b, "c =", c);

      • 查找 a:在 FuncVE 中找到 a: 10
      • 查找 b:在 FuncVE 中找到 b: 20
      • 查找 c:在 FuncVE 中找到 c: 30
      • 输出 a=10, b=20, c=30
    • // console.log("After block: d =", d);

      • 尝试查找 d:在 FuncVE 中找不到。沿着 OuterLexicalEnvironmentReference 向上,在全局词法环境中也找不到。抛出 ReferenceError。这是因为 d 所在的 BlockLE 已经不再是当前的 LexicalEnvironment 且不再可访问。

这个详细的步骤展示了 LexicalEnvironment 如何在执行过程中动态地在 FuncVEBlockLE 之间切换,而 VariableEnvironment 则始终保持指向 FuncVE。这正是 let/const 实现块级作用域的底层机制。

4.3 为什么需要两者?历史与演进

这个分离设计,尤其是 LexicalEnvironment 的动态性,主要是为了适应 JavaScript 语言的演进:

  • var 的历史包袱var 只有函数作用域或全局作用域,并且存在提升。VariableEnvironment 很好地封装了这种旧有的行为。
  • ES6 的块级作用域letconst 引入了块级作用域,这需要一种更细粒度的作用域管理机制。如果仅仅依靠 VariableEnvironment 这种“创建时快照”的结构,将无法实现块级作用域。因此,LexicalEnvironment 被设计成可以动态切换,以在进入和退出块时反映新的作用域。
  • 性能与简洁性:将 var/function 声明的静态解析(在创建阶段)与 let/const 的动态块级解析分开,有助于引擎在不同阶段优化代码执行。

五、实际应用与最佳实践

理解词法环境和变量环境不仅仅是学术上的探讨,它对我们编写高质量的 JavaScript 代码具有直接的指导意义。

5.1 明确作用域边界

  • var 的陷阱var 声明的变量会提升到其所在函数的变量环境,导致它们在整个函数体内(甚至在声明之前)都可访问。这常常导致意料之外的行为,尤其是在循环和条件语句中。

    for (var i = 0; i < 3; i++) {
        setTimeout(function() {
            console.log(i); // 3, 3, 3 (i 在循环结束后为 3,且所有闭包共享同一个 i)
        }, 100);
    }
    console.log("Final i:", i); // Final i: 3

    这里的 i 是在全局/函数变量环境中,每次循环都修改了同一个 i

  • let/const 的优势letconst 声明的变量存在于它们各自的块级词法环境中。每次循环迭代都会创建一个新的块级词法环境,使得变量在每次迭代中都是独立的。

    for (let j = 0; j < 3; j++) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 (每次迭代的 j 都是独立的)
        }, 100);
    }
    // console.log("Final j:", j); // ReferenceError: j is not defined

    这里的 j 在每次循环迭代时都在一个新的块级词法环境中,因此 setTimeout 捕获到的是每次迭代不同的 j 值。

5.2 避免“暂时性死区”(TDZ)问题

letconst 变量在它们的代码块顶部就被“提升”了,但它们直到声明语句被执行才会被初始化。在这之间的区域就是 TDZ。尝试在 TDZ 内访问这些变量会导致 ReferenceError

function tdzExample() {
    // console.log(x); // ReferenceError (x 处于 TDZ)
    let x = 10;
    console.log(x); // 10
}
tdzExample();

理解 let/const 是如何被添加到其块级词法环境中,以及它们在初始化之前的状态(TDZ),有助于避免这类错误。

5.3 更好的代码可读性与维护性

使用 letconst 能够使得变量的作用域更加明确,只在需要的地方可见。这减少了变量污染和意外的副作用,提高了代码的可读性和可维护性。

5.4 推荐使用 letconst

鉴于 letconst 提供了更清晰、更可预测的作用域规则,并且避免了 var 带来的许多常见问题,现代 JavaScript 实践强烈推荐优先使用 letconst。只有在极少数需要 var 的特定行为(例如在非常老的浏览器环境中)时才考虑使用它。

六、结语

今天我们深入探讨了 JavaScript 中的词法环境和变量环境。我们了解到,词法环境是一个抽象且动态的概念,它定义了变量和函数在代码中的可访问性,并构成了作用域链的基础。而变量环境则是执行上下文创建时的一个特殊词法环境,专门用于处理 var 声明的变量和函数声明的提升。

核心的区别在于,VariableEnvironment 在执行上下文的生命周期内通常是固定的,而 LexicalEnvironment 则会随着代码的执行,特别是进入和退出 let/const 块级作用域时,动态地更新以反映当前活跃的作用域。

掌握这两个概念,能够帮助我们更深刻地理解 JavaScript 的作用域机制、变量提升、闭包的本质以及 varletconst 之间的行为差异。这是编写健壮、可维护和高效 JavaScript 代码的必备知识。希望通过这次讲座,大家对这两个概念有了更清晰的认识。感谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注