JavaScript 词法作用域(Lexical Scoping)与 变量提升(Hoisting):从执行上下文初始化阶段看函数与变量的创建序

各位开发者,下午好!

今天,我们将深入探讨 JavaScript 中两个核心且经常被误解的概念:词法作用域(Lexical Scoping)与变量提升(Hoisting)。这两个机制是理解 JavaScript 代码执行流程、尤其是其背后的执行上下文初始化阶段的关键。它们不仅决定了变量和函数的可见性,更深刻地影响着我们编写和调试代码的方式。作为一名编程专家,我的目标是带大家透过现象看本质,从 JavaScript 引擎的视角,解构这些概念,让它们变得清晰透明。

我们将从 JavaScript 的执行模型入手,逐步深入到执行上下文的创建阶段,详细剖析在这个阶段,函数与变量是如何被创建、初始化,以及它们如何共同构建起我们所熟知的“作用域链”。准备好了吗?让我们开始这场探索之旅。

一、JavaScript 执行模型:一切的起点

JavaScript 是一种单线程、非阻塞、异步的语言。它的代码执行是基于“执行上下文”(Execution Context)栈的。每当 JavaScript 引擎需要执行一段代码时,它都会创建一个新的执行上下文,并将其推入执行上下文栈。当这段代码执行完毕,对应的执行上下文就会从栈中弹出。

执行上下文可以分为几种类型:

  1. 全局执行上下文 (Global Execution Context):当 JavaScript 脚本开始执行时,会创建唯一的全局执行上下文。它在整个应用程序生命周期内都存在,并负责管理全局变量和函数。在浏览器环境中,window 对象就是全局对象;在 Node.js 中,则是 global 对象。this 在全局上下文中指向全局对象。
  2. 函数执行上下文 (Function Execution Context):每当调用一个函数时,都会创建一个新的函数执行上下文。每个函数调用都会有自己独立的上下文,即使是同一个函数的多次调用,也会创建多个独立的上下文。
  3. Eval 执行上下文 (Eval Execution Context):使用 eval() 函数执行的代码也会有自己的执行上下文,但由于 eval 的安全性和性能问题,在现代 JavaScript 开发中极少推荐使用。

今天,我们的重点将放在全局和函数执行上下文上。理解它们的生命周期,特别是它们的“创建阶段”,是掌握词法作用域和变量提升的关键。

二、执行上下文的生命周期:创建与执行

每个执行上下文的生命周期都包含两个主要阶段:

  1. 创建阶段 (Creation Phase):在这个阶段,执行上下文被创建,但代码尚未开始执行。JavaScript 引擎会在此阶段完成一系列重要的准备工作,包括:

    • 创建词法环境(LexicalEnvironment)组件。
    • 创建变量环境(VariableEnvironment)组件。
    • 确定 this 的指向(ThisBinding)。
    • 这些组件的初始化是词法作用域和变量提升机制的核心。
  2. 执行阶段 (Execution Phase):在创建阶段完成后,JavaScript 引擎开始逐行执行代码。在这个阶段,变量被赋值,函数被调用,以及所有实际的程序逻辑都会被执行。

我们的深入探讨将聚焦于创建阶段,因为它揭示了函数和变量如何在代码执行之前就“准备就绪”。

2.1 词法环境 (LexicalEnvironment) 与 变量环境 (VariableEnvironment)

在 ES6 之前,JavaScript 规范中主要描述的是“变量环境”(VariableEnvironment)。而 ES6 引入了 letconst 关键字后,为了区分它们与 var 的不同行为,规范引入了“词法环境”(LexicalEnvironment)的概念。在现代 JavaScript 引擎的实现中,通常会将这两者融合或让 LexicalEnvironment 扮演更核心的角色。

从概念上讲:

  • 词法环境 (LexicalEnvironment):这是一个抽象的概念,用于存储当前执行上下文中的所有声明(包括变量、函数、类等)的标识符和它们的值的映射。它由两部分组成:

    • 环境记录器 (Environment Record):实际存储变量和函数声明的地方。
    • 外部词法环境引用 (Outer Lexical Environment Reference):指向外部(父级)词法环境的引用,这是构建作用域链的关键。
  • 变量环境 (VariableEnvironment):在 ES6 之后,它实际上是词法环境的一个内部组件,但它专门用于处理 var 声明的变量和函数声明。在全局上下文中,它还包含了 windowglobal 对象的属性。我们可以理解为,varfunction 声明会被特殊处理并放置在 VariableEnvironment 中(或 LexicalEnvironment 的一个特定部分),而 letconst 声明则放置在 LexicalEnvironment 的另一个部分。

为了简化理解,我们可以将 LexicalEnvironment 视为一个“容器”,它在创建阶段被初始化,并保存了该上下文中的所有本地声明。

2.1.1 环境记录器 (Environment Record)

环境记录器是词法环境的核心部分,它负责存储当前作用域内的所有绑定(binding)。根据声明类型的不同,环境记录器又分为两种:

  1. 声明式环境记录器 (Declarative Environment Record)

    • 用于存储函数声明 (function)、let 声明、const 声明和 class 声明。
    • 它直接存储这些标识符到它们的实际值或一个“未初始化”状态的映射。
    • 对于 letconst,在它们的代码行被执行之前,它们的状态是“未初始化”(uninitialized),处于暂时性死区 (Temporal Dead Zone, TDZ)
  2. 对象环境记录器 (Object Environment Record)

    • 主要用于全局执行上下文,它将全局对象(如浏览器中的 window)的属性暴露为环境记录器中的绑定。
    • 同时,它也存储 var 声明的变量,这些变量也会成为全局对象的属性。
    • with 语句中也会创建对象环境记录器,但在严格模式下不推荐使用 with

2.1.2 外部词法环境引用 (Outer Lexical Environment Reference)

这是词法环境的另一个至关重要的部分。它指向创建当前词法环境的那个词法环境。简单来说,它指向上层(父级)的作用域。正是这个引用,将所有独立的词法环境串联起来,形成了作用域链。当 JavaScript 引擎需要查找一个变量时,它会首先在当前词法环境的环境记录器中查找,如果找不到,就会沿着 Outer Lexical Environment Reference 向上查找,直到找到全局词法环境。如果仍然找不到,就会抛出 ReferenceError

三、词法作用域 (Lexical Scoping):代码编写时决定

词法作用域,又称静态作用域,意味着变量和函数的可见性(即作用域)是在代码编写阶段(词法分析阶段)就被确定下来的,而不是在代码执行阶段。换句话说,函数在哪里被定义,它的作用域就决定了。它不关心函数在哪里被调用,只关心函数在哪里被声明。

这个概念通过 Outer Lexical Environment Reference 在执行上下文的创建阶段得以体现。当一个函数被创建时,它会“记住”自己被创建时的那个词法环境。这个“记住”就是通过其内部属性 [[Environment]] 来实现的,这个属性存储了其创建时的 Outer Lexical Environment Reference

让我们看一个简单的例子:

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

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

    function innerFunction() {
        var innerVar = "我是内部函数变量";
        console.log(innerVar);  // 访问 innerFunction 自身的变量
        console.log(outerVar);  // 访问 outerFunction 的变量
        console.log(globalVar); // 访问全局变量
    }

    innerFunction(); // 调用内部函数
    // console.log(innerVar); // 错误:innerVar 在这里不可访问
}

outerFunction(); // 调用外部函数
// console.log(outerVar); // 错误:outerVar 在这里不可访问

执行上下文创建阶段的视角:

  1. 全局执行上下文创建

    • LexicalEnvironmentEnvironment Record 包含 globalVar (值为 undefined,因为是 var) 和 outerFunction (值为完整的函数对象)。
    • Outer Lexical Environment Referencenull (全局上下文的外部引用)。
  2. 调用 outerFunction() 时,创建 outerFunction 执行上下文

    • LexicalEnvironmentEnvironment Record 包含 outerVar (值为 undefined) 和 innerFunction (值为完整的函数对象)。
    • Outer Lexical Environment Reference 指向全局执行上下文的词法环境。这是因为 outerFunction 是在全局作用域中定义的。
  3. 调用 innerFunction() 时,创建 innerFunction 执行上下文

    • LexicalEnvironmentEnvironment Record 包含 innerVar (值为 undefined)。
    • Outer Lexical Environment Reference 指向outerFunction 执行上下文的词法环境。这是因为 innerFunction 是在 outerFunction 内部定义的。

变量查找过程:

innerFunction 内部执行 console.log(outerVar) 时:

  1. 首先在 innerFunction 自身的 LexicalEnvironmentEnvironment Record 中查找 outerVar。找不到。
  2. 通过 innerFunctionOuter Lexical Environment Reference 上溯到 outerFunctionLexicalEnvironment
  3. outerFunctionEnvironment Record 中找到 outerVar。使用它的值。

这就是词法作用域的工作原理。函数的作用域链在它被定义的那一刻就已经固定了。

四、变量提升 (Hoisting):声明在先,赋值在后

变量提升是 JavaScript 中一个经常引起混淆的特性,它并不是指代码真的被“移动”到文件顶部。更准确地说,变量提升是 JavaScript 引擎在执行上下文的创建阶段,将变量和函数的声明“注册”到其对应的词法环境(或变量环境)中的行为。这意味着,无论变量或函数在代码中实际声明的位置在哪里,它们的声明都会在代码执行之前就被处理。

然而,不同类型的声明(function, var, let, const, class)在提升时的初始化行为是不同的。

4.1 函数声明提升 (Function Declarations Hoisting)

函数声明是提升行为中最“完全”的一种。在执行上下文的创建阶段,函数声明不仅被注册到环境记录器中,而且会被完整地初始化,即函数对象会被创建并赋值给对应的标识符。这意味着你可以在函数声明之前调用它。

console.log(greet("Alice")); // 输出: Hello, Alice!

function greet(name) {
    return "Hello, " + name + "!";
}

// 另一个例子
sayHello(); // 输出: Hello!

function sayHello() {
    console.log("Hello!");
}

sayHello = function() { // 函数表达式,不会被提升
    console.log("Goodbye!");
};

sayHello(); // 输出: Goodbye!

执行上下文创建阶段的视角:

  1. 全局执行上下文创建。
  2. JavaScript 引擎扫描代码,发现 greet 函数声明。在 LexicalEnvironmentEnvironment Record 中创建 greet 绑定,并将其值初始化为完整的 greet 函数对象。
  3. 同样,扫描到第一个 sayHello 函数声明,创建 sayHello 绑定,并将其值初始化为完整的 sayHello 函数对象。

因此,在代码执行阶段开始时,greetsayHello 函数就已经完全可用了。

4.2 var 变量提升 (var Variable Hoisting)

var 声明的变量也会被提升,但其初始化行为与函数声明不同。在执行上下文的创建阶段,var 变量会被注册到环境记录器中,并被初始化为 undefined。实际的赋值操作则发生在代码的执行阶段。

console.log(myVar); // 输出: undefined
var myVar = 10;
console.log(myVar); // 输出: 10

// 另一个例子
var a = 1;
function test() {
    console.log(a); // 输出: undefined (不是 1)
    var a = 2;
    console.log(a); // 输出: 2
}
test();
console.log(a); // 输出: 1

执行上下文创建阶段的视角:

例子一:

  1. 全局执行上下文创建。
  2. JavaScript 引擎扫描代码,发现 var myVar = 10;。在 LexicalEnvironmentEnvironment Record 中创建 myVar 绑定,并将其值初始化为 undefined
  3. 代码进入执行阶段:
    • console.log(myVar) 执行,此时 myVar 的值是 undefined
    • myVar = 10; 执行,myVar 的值被更新为 10
    • console.log(myVar) 执行,此时 myVar 的值是 10

例子二:test 函数内部

  1. 调用 test() 时,创建 test 函数执行上下文。
  2. JavaScript 引擎扫描 test 函数内部代码,发现 var a = 2;。在 test 函数的 LexicalEnvironmentEnvironment Record 中创建局部 a 绑定,并将其值初始化为 undefined
  3. 代码进入执行阶段:
    • console.log(a) 执行,此时 test 作用域内的 a 的值是 undefined注意:这里不会去查找全局的 a,因为 test 内部已经声明了 a,发生了“变量遮蔽” (variable shadowing)。
    • a = 2; 执行,局部 a 的值被更新为 2
    • console.log(a) 执行,此时局部 a 的值是 2

这种行为就是 var 变量提升的典型表现:声明被提升,但初始化值是 undefined

4.3 letconst 变量提升(Temporal Dead Zone – TDZ)

letconst 声明的变量也存在提升行为,但它们的提升方式更为严格。在执行上下文的创建阶段,letconst 变量的声明会被注册到环境记录器中。但是,它们不会被初始化为 undefined。相反,它们会处于一种“未初始化”状态,直到它们在代码中实际的声明语句被执行。在这段从作用域开始到声明语句执行之间的区域,我们称之为暂时性死区 (Temporal Dead Zone, TDZ)

在 TDZ 中访问 letconst 变量会导致 ReferenceError

console.log(myLetVar); // ReferenceError: Cannot access 'myLetVar' before initialization
let myLetVar = 20;
console.log(myLetVar); // 输出: 20

// 另一个例子
console.log(myConstVar); // ReferenceError: Cannot access 'myConstVar' before initialization
const myConstVar = 30;
console.log(myConstVar); // 输出: 30

// TDZ 的范围
function checkTDZ() {
    // 这里是 myBlockVar 的 TDZ
    console.log(myBlockVar); // ReferenceError
    let myBlockVar = "hello";
    console.log(myBlockVar); // "hello"
}
checkTDZ();

执行上下文创建阶段的视角:

  1. 全局执行上下文创建。
  2. JavaScript 引擎扫描代码,发现 let myLetVar = 20;。在 LexicalEnvironmentEnvironment Record 中创建 myLetVar 绑定,并将其标记为未初始化
  3. 代码进入执行阶段:
    • console.log(myLetVar) 尝试访问 myLetVar。由于 myLetVar 处于未初始化状态(TDZ),引擎抛出 ReferenceError
    • 如果 ReferenceError 没有发生,let myLetVar = 20; 会将 myLetVar 从 TDZ 中移出,并赋值为 20
    • console.log(myLetVar) 此时可以正常访问 myLetVar

const 的行为与 let 相同,唯一的区别是 const 声明的变量一旦赋值就不能再修改。

4.4 class 声明提升

class 声明(class declaration)的行为与 letconst 类似,也存在暂时性死区。在类声明的代码行被执行之前,尝试访问该类会导致 ReferenceError

// console.log(MyClass); // ReferenceError: Cannot access 'MyClass' before initialization
class MyClass {
    constructor(name) {
        this.name = name;
    }
}
const instance = new MyClass("Test");
console.log(instance.name); // 输出: Test

执行上下文创建阶段的视角:

  1. 全局执行上下文创建。
  2. JavaScript 引擎扫描代码,发现 class MyClass { ... }。在 LexicalEnvironmentEnvironment Record 中创建 MyClass 绑定,并将其标记为未初始化(处于 TDZ)。
  3. 代码进入执行阶段:
    • 尝试访问 MyClass 会抛出 ReferenceError
    • class MyClass { ... } 语句执行后,MyClass 从 TDZ 中移出,并被赋值为实际的类定义。

4.5 函数表达式 (Function Expressions)

需要特别注意的是,函数表达式(Function Expression)不会被提升。它们被视为普通的变量赋值,其提升行为取决于用于声明该变量的关键字 (var, let, const)。

// funcDecl(); // 输出: "Hello from declaration" (函数声明被提升)
// funcExpr(); // TypeError: funcExpr is not a function (如果是 var) 或 ReferenceError (如果是 let/const)

function funcDecl() {
    console.log("Hello from declaration");
}

var funcExpr = function() {
    console.log("Hello from expression");
};

funcDecl(); // 输出: "Hello from declaration"
funcExpr(); // 输出: "Hello from expression"

// 使用 let 的函数表达式
// funcLetExpr(); // ReferenceError: Cannot access 'funcLetExpr' before initialization
let funcLetExpr = function() {
    console.log("Hello from let expression");
};
funcLetExpr(); // 输出: "Hello from let expression"

执行上下文创建阶段的视角:

  1. 全局执行上下文创建。
  2. 扫描到 funcDecl 函数声明:在 LexicalEnvironmentEnvironment Record 中创建 funcDecl 绑定,并初始化为完整的函数对象。
  3. 扫描到 var funcExpr = function() { ... };:在 LexicalEnvironmentEnvironment Record 中创建 funcExpr 绑定,并初始化为 undefined
  4. 扫描到 let funcLetExpr = function() { ... };:在 LexicalEnvironmentEnvironment Record 中创建 funcLetExpr 绑定,并标记为未初始化(TDZ)。

代码执行阶段:

  • funcDecl() 可以立即执行,因为其在创建阶段已完全初始化。
  • funcExpr()var funcExpr = ... 语句执行之前调用会失败,因为此时 funcExpr 的值是 undefinedundefined 不是一个函数,所以尝试调用它会产生 TypeError
  • funcLetExpr()let funcLetExpr = ... 语句执行之前调用会抛出 ReferenceError,因为它在 TDZ 中。

4.6 总结 Hoisting 行为

声明类型 提升行为 初始化值(创建阶段) 访问行为(声明前)
function 声明 完全提升(声明和定义) 完整函数对象 可访问,可调用
var 变量 声明提升,定义不提升 undefined 可访问,值为 undefined
let 变量 声明提升,定义不提升(进入 TDZ) 未初始化 抛出 ReferenceError (在 TDZ 中)
const 变量 声明提升,定义不提升(进入 TDZ) 未初始化 抛出 ReferenceError (在 TDZ 中)
class 声明 声明提升,定义不提升(进入 TDZ) 未初始化 抛出 ReferenceError (在 TDZ 中)
函数表达式 (var) var 变量提升规则 undefined 可访问,值为 undefined,尝试调用会抛出 TypeError
函数表达式 (let) let 变量提升规则(进入 TDZ) 未初始化 抛出 ReferenceError (在 TDZ 中)

五、词法作用域、变量提升与执行上下文创建阶段的协同作用

现在,让我们把词法作用域和变量提升这两个概念,与执行上下文的创建阶段紧密结合起来,看看它们是如何协同工作的。

当 JavaScript 引擎进入一个新的执行上下文的创建阶段时,它会执行以下关键步骤:

  1. 确定 Outer Lexical Environment Reference

    • 这是词法作用域的核心。引擎根据当前代码的物理位置(即它在源代码中被定义的位置),确定当前词法环境应该指向哪个外部词法环境。这构建了作用域链的基础。
  2. 创建 Environment Record 并处理函数声明

    • 引擎扫描当前作用域中的所有 function 声明。
    • 对于每个函数声明,引擎会在 Environment Record 中创建一个绑定,并将该标识符的值设置为一个完整的函数对象。这意味着函数声明在代码执行前就已经完全可用。
  3. 处理 var 变量声明

    • 引擎扫描当前作用域中的所有 var 变量声明。
    • 对于每个 var 声明,引擎会在 Environment Record 中创建一个绑定,并将其值初始化为 undefined
    • 如果存在同名的 var 声明或 function 声明,function 声明会优先,但 var 会被忽略(不会重新赋值 undefined)。
  4. 处理 let, const, class 声明

    • 引擎扫描当前作用域中的所有 let, const, class 声明。
    • 对于每个这样的声明,引擎会在 Environment Record 中创建一个绑定,但将其状态标记为未初始化。它们将处于暂时性死区 (TDZ),直到代码执行到实际的声明语句。

所有这些步骤都发生在代码的实际执行之前。 只有当这些准备工作完成后,执行上下文才会进入执行阶段,此时代码才开始逐行运行。

让我们通过一个综合的例子来理解:

var x = 10;

function foo() {
    console.log(x); // 输出: undefined
    var x = 20;
    console.log(x); // 输出: 20

    function bar() {
        console.log(x); // 输出: 20
        console.log(y); // ReferenceError: Cannot access 'y' before initialization
        let y = 30;
        console.log(y); // 输出: 30
    }
    bar();
}

foo();
console.log(x); // 输出: 10

逐步分析执行过程:

  1. 全局执行上下文创建阶段:

    • Outer Lexical Environment Reference: null
    • Environment Record:
      • x: 绑定创建,初始化为 undefined (来自 var x = 10;)。
      • foo: 绑定创建,初始化为完整的 foo 函数对象 (来自 function foo() { ... })。
  2. 全局执行上下文执行阶段:

    • var x = 10;: x 的值从 undefined 更新为 10
    • foo();: 调用 foo 函数,创建 foo 函数执行上下文。
  3. foo 函数执行上下文创建阶段:

    • Outer Lexical Environment Reference: 指向全局执行上下文的词法环境 (因为 foo 在全局作用域定义)。
    • Environment Record:
      • x: 绑定创建,初始化为 undefined (来自 var x = 20;遮蔽了全局的 x)。
      • bar: 绑定创建,初始化为完整的 bar 函数对象 (来自 function bar() { ... })。
  4. foo 函数执行上下文执行阶段:

    • console.log(x);: 查找 x。在 fooEnvironment Record 中找到 x,其值为 undefined。输出 undefined
    • var x = 20;: foo 作用域内的 x 的值从 undefined 更新为 20
    • console.log(x);: 查找 x。在 fooEnvironment Record 中找到 x,其值为 20。输出 20
    • bar();: 调用 bar 函数,创建 bar 函数执行上下文。
  5. bar 函数执行上下文创建阶段:

    • Outer Lexical Environment Reference: 指向foo 函数执行上下文的词法环境 (因为 barfoo 内部定义)。
    • Environment Record:
      • y: 绑定创建,标记为未初始化 (来自 let y = 30;,处于 TDZ)。
  6. bar 函数执行上下文执行阶段:

    • console.log(x);: 查找 x
      • barEnvironment Record 中查找 x,未找到。
      • 沿着 Outer Lexical Environment Reference 上溯到 fooLexicalEnvironment
      • fooEnvironment Record 中找到 x,其值为 20。输出 20
    • console.log(y);: 查找 y。在 barEnvironment Record 中找到 y,但其状态为未初始化。抛出 ReferenceError
    • (假设没有 ReferenceError,或者我们把 console.log(y) 放在 let y = 30; 之后)
    • let y = 30;: y 从 TDZ 中移出,并赋值为 30
    • console.log(y);: 查找 y。在 barEnvironment Record 中找到 y,其值为 30。输出 30
  7. bar 函数执行完毕,其执行上下文从栈中弹出。

  8. foo 函数执行完毕,其执行上下文从栈中弹出。

  9. 回到全局执行上下文执行阶段:

    • console.log(x);: 查找 x。在全局的 Environment Record 中找到 x,其值为 10。输出 10

这个详细的流程展示了词法作用域(通过 Outer Lexical Environment Reference 决定变量查找路径)和变量提升(在创建阶段初始化 varundefinedlet/const 进入 TDZ,function 完全初始化)如何共同塑造了 JavaScript 代码的行为。

六、闭包:词法作用域的终极体现

理解了词法作用域和执行上下文的创建阶段,我们就能真正理解闭包(Closure)这个强大的概念。

闭包的定义:当一个函数能够记住并访问它被创建时的那个词法环境,即使它在那个词法环境之外被调用,那么这个函数和它所“记住”的词法环境就构成了一个闭包。

这正是 Outer Lexical Environment Reference 的威力所在。当内部函数被定义时,它捕获了其外部函数的词法环境。即使外部函数执行完毕,其对应的执行上下文从栈中弹出,但由于内部函数(闭包)仍然持有对该外部词法环境的引用,这个外部词法环境并不会被垃圾回收,而是会一直存在,直到闭包不再被引用。

function makeCounter() {
    let count = 0; // count 变量属于 makeCounter 的词法环境

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

const counter1 = makeCounter(); // counter1 捕获了 makeCounter 第一次调用时的词法环境
const counter2 = makeCounter(); // counter2 捕获了 makeCounter 第二次调用时的词法环境(独立的)

counter1(); // 输出: 1
counter1(); // 输出: 2

counter2(); // 输出: 1 (独立的 count 变量)
counter2(); // 输出: 2

闭包的运作机制:

  1. 调用 makeCounter() (第一次)

    • 创建一个 makeCounter 函数执行上下文。
    • LexicalEnvironment 包含 count 变量(初始化为 0)。
    • 返回一个匿名函数。这个匿名函数被创建时,它的 [[Environment]] 内部属性会存储对当前 makeCounter 执行上下文的 LexicalEnvironment 的引用。
    • makeCounter 执行完毕,其执行上下文从栈中弹出。但由于匿名函数持有对其 LexicalEnvironment 的引用,这个 LexicalEnvironment (包括 count 变量) 不会被垃圾回收。
  2. counter1() 被调用

    • 创建一个匿名函数执行上下文。
    • Outer Lexical Environment Reference 指向第一次调用 makeCounter 时创建的那个词法环境。
    • 在那个词法环境中找到 count,对其进行 ++ 操作。
  3. 调用 makeCounter() (第二次)

    • 再次创建一个全新makeCounter 函数执行上下文。
    • LexicalEnvironment 包含一个全新count 变量(初始化为 0)。
    • 返回另一个匿名函数。这个新的匿名函数捕获的是第二次调用 makeCounter 时创建的词法环境。

这就是为什么 counter1counter2 拥有独立的计数器。每个闭包都“记住”了它自己被创建时的独立词法环境。

闭包的常见应用场景:

  • 数据私有化:创建模块化的代码,隐藏内部实现细节。
  • 工厂函数:生成具有特定配置或状态的函数。
  • 事件处理程序:在事件触发时能够访问定义时的环境变量。
  • 函数柯里化/偏函数应用:创建更灵活的函数。

七、实践启示与最佳实践

理解词法作用域和变量提升,特别是它们在执行上下文创建阶段的机制,对于编写健壮、可维护的 JavaScript 代码至关重要。

  1. 优先使用 letconst

    • letconst 引入了块级作用域和暂时性死区,这使得变量的行为更加可预测,减少了因 var 提升导致的一些意外情况(如循环变量问题)。
    • 它们鼓励开发者先声明后使用,避免了 undefined 的困扰。
    • const 更是强制了变量的不可变性(对于基本类型是值不可变,对于引用类型是引用不可变),有助于代码的稳定性和理解。
  2. 避免隐式全局变量

    • 在非严格模式下,不使用 var, let, const 声明的变量会被自动视为全局变量。这很容易造成变量污染和命名冲突。始终显式声明你的变量。
  3. 将函数声明放在文件或作用域的顶部

    • 尽管函数声明会被完全提升,但为了代码的可读性和一致性,最好还是将它们放在逻辑单元的顶部。这使得读者一眼就能看到该作用域内可用的函数。
  4. 警惕 var 的变量提升陷阱

    • 尤其是在循环中,使用 var 声明的循环变量会“泄露”到循环外部,或者被所有迭代共享同一个变量实例,这通常不是我们期望的行为。
    • for (var i = 0; i < 3; i++) {
          setTimeout(function() {
              console.log(i); // 总是输出 3, 3, 3
          }, 100 * i);
      }
      // 使用 let 解决
      for (let j = 0; j < 3; j++) {
          setTimeout(function() {
              console.log(j); // 输出 0, 1, 2
          }, 100 * j);
      }
    • var 的例子中,i 是函数作用域的,setTimeout 中的匿名函数形成了闭包,捕获了对同一个 i 的引用。当 setTimeout 执行时,循环早已结束,i 的最终值是 3
    • 使用 let 时,每次循环迭代都会为 j 创建一个新的绑定,因此每个闭包都捕获了它自己迭代中的 j 值。
  5. 理解闭包的生命周期

    • 闭包会保留对其外部词法环境的引用,这可能导致内存泄漏,如果闭包不再需要但仍然被引用着。及时解除不再需要的闭包引用。

八、掌握 JavaScript 运行时

通过深入剖析 JavaScript 的执行上下文、词法环境、作用域链以及变量提升的各种行为,我们得以揭开 JavaScript 运行时机制的神秘面纱。这些看似复杂的概念,实则环环相扣,共同构建了 JavaScript 代码执行的底层逻辑。掌握它们,意味着我们不再仅仅是代码的编写者,更是其行为的预测者和掌控者。这将使我们能够编写出更健壮、更高效、更易于维护的 JavaScript 应用程序。

发表回复

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