JavaScript 的变量提升(Hoisting):从执行上下文初始化阶段看函数与变量的创建时序

各位来宾,各位技术爱好者,大家下午好!

今天,我们将深入探讨JavaScript中一个既常见又常被误解的现象——变量提升(Hoisting)。与其简单地将其视为一种“奇怪的行为”,不如从其根源——JavaScript执行上下文(Execution Context)的初始化阶段——来剖析它。我们将聚焦于函数与变量在代码执行前,它们是如何被“创建”并安排就位的。理解这一点,将帮助我们拨开迷雾,真正掌握JavaScript的底层运行机制。

一、 JavaScript执行的宏观视角:执行上下文

在JavaScript的世界里,任何代码的执行都离不开执行上下文(Execution Context)。你可以把执行上下文想象成一个独立的、隔离的环境,它负责管理代码执行所需的一切。当JavaScript引擎开始解析并执行代码时,它会创建一个又一个执行上下文,并将它们组织成一个栈(Execution Context Stack,也称为调用栈)。

JavaScript中主要有三种类型的执行上下文:

  1. 全局执行上下文(Global Execution Context):这是最基础的上下文,当JavaScript文件被浏览器加载或Node.js进程启动时创建。它代表全局作用域,this关键字在这里通常指向全局对象(浏览器中是window,Node.js中是global)。
  2. 函数执行上下文(Function Execution Context):每当一个函数被调用时,就会创建一个新的函数执行上下文。每个函数都有自己的作用域,this的指向取决于函数的调用方式。
  3. Eval函数执行上下文(Eval Function Execution Context):在eval()函数内部执行的代码会创建自己的执行上下文,但在实际开发中应尽量避免使用eval,因为它存在安全风险和性能问题。

今天,我们的重点将放在全局和函数执行上下文上。

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

一个执行上下文的生命周期可以分为两个主要阶段:

  1. 创建阶段(Creation Phase):在这个阶段,引擎会扫描并解析代码,但不会真正执行。它会做一些准备工作,包括:
    • 创建变量环境(Variable Environment)。
    • 创建词法环境(Lexical Environment)。
    • 确定this的指向(this Binding)。
    • 变量提升就发生在这个阶段!
  2. 执行阶段(Execution Phase):在创建阶段完成后,引擎开始逐行执行代码,对变量进行赋值,调用函数等。

理解这两个阶段至关重要,因为变量提升并非代码在物理上被移动到顶部,而是在创建阶段,引擎就已经知道了所有变量和函数的声明,并为它们分配了内存空间。

二、 执行上下文的内部结构:词法环境与变量环境

为了更深入地理解变量提升,我们需要剖析执行上下文在创建阶段的具体构成。一个执行上下文主要包含以下三个核心组件:

  1. 词法环境(Lexical Environment):这是JavaScript中实现作用域机制的核心。它是一个抽象的概念,用于保存标识符(变量名、函数名)到变量实际值的映射。它包含两个主要部分:
    • 环境记录(Environment Record):这是存储变量和函数声明的具体地方。
      • 声明式环境记录(Declarative Environment Record):用于存储函数声明、letconstclass声明。
      • 对象式环境记录(Object Environment Record):在全局上下文中使用,用于存储var声明和函数声明(同时也是全局对象的属性),以及with语句。
    • 外部环境引用(Outer Environment Reference):指向外部(父级)词法环境的引用。这正是作用域链(Scope Chain)的实现机制,通过这个引用,JavaScript引擎可以在当前环境中找不到某个变量时,向上级环境查找。
  2. 变量环境(Variable Environment):在ES6之前,变量环境是词法环境的唯一实现。ES6引入letconst后,为了区分varfunction的特殊提升行为,词法环境被细分。
    • 在ES6及以后,变量环境专门用来处理var声明和函数声明的绑定。 它的结构与词法环境类似,也包含一个环境记录和一个外部环境引用。
    • 在执行上下文的创建阶段,词法环境和变量环境最初是相同的。 但当代码执行时,它们可能会因为letconst等块级作用域变量的引入而产生差异。
  3. this绑定(this Binding):在创建阶段,引擎还会确定this关键字在当前执行上下文中的指向。

让我们用一个表格来概括执行上下文创建阶段的结构:

组件名称 主要作用 包含内容(创建阶段)
词法环境 (Lexical Environment) 存储所有标识符(变量、函数、类)的绑定,并负责作用域链的构建。 环境记录 (Environment Record):
声明式环境记录: 存储letconstclassfunction声明。
对象式环境记录 (仅全局上下文): 存储varfunction声明作为全局对象的属性。
外部环境引用 (Outer Environment Reference): 指向父级词法环境。
变量环境 (Variable Environment) 专门处理varfunction声明的绑定。在ES6之前,它就是词法环境。在ES6之后,varfunction的提升行为主要在此处体现。 与词法环境类似,但其环境记录仅处理varfunction声明。
this绑定 (this Binding) 确定当前执行上下文中this关键字的指向。 根据上下文类型(全局、函数、方法等)和调用方式确定this的值。

重点:变量提升的奥秘就隐藏在词法环境和变量环境的“环境记录”在创建阶段的填充方式上。

三、 var 变量的提升:声明被提升,初始化为 undefined

当JavaScript引擎进入执行上下文的创建阶段时,它会扫描当前作用域内的所有var变量声明。对于这些声明,引擎会做两件事:

  1. 将变量声明(Identifier)添加到变量环境的环境记录中。
  2. 为这些变量分配内存,并将其初始值设置为 undefined

这意味着,在代码的任何位置,只要var变量被声明了,即使它在实际代码执行到声明语句之前被访问,它也已经存在于环境中,只是其值是undefined

让我们看一个例子:

console.log(myVar); // 输出: undefined
var myVar = "Hello Hoisting";
console.log(myVar); // 输出: Hello Hoisting

解析:

  1. 创建阶段:
    • JavaScript引擎扫描全局执行上下文。
    • 它发现var myVar = "Hello Hoisting";这行代码。
    • 引擎在全局环境的变量环境(同时也是词法环境)的环境记录中创建myVar这个绑定。
    • myVar被初始化为undefined
    • 此时,环境记录大致是这样:{ myVar: undefined }
  2. 执行阶段:
    • console.log(myVar);:此时myVar在环境中已经存在,值为undefined,所以打印undefined
    • myVar = "Hello Hoisting";:将字符串值赋给myVarmyVar的值变为"Hello Hoisting"
    • console.log(myVar);:此时myVar的值是"Hello Hoisting",所以打印"Hello Hoisting"

这就像引擎在代码执行前,默默地把var声明“搬”到了作用域的顶部,并给它一个默认值undefined

等价于(但不是真正发生的代码移动):

var myVar; // 声明被提升,并初始化为 undefined
console.log(myVar); // 输出: undefined
myVar = "Hello Hoisting";
console.log(myVar); // 输出: Hello Hoisting

重复声明的var变量:

如果同一个作用域内有多个同名var变量声明,JavaScript引擎会忽略后续的声明,但赋值操作依然有效。

console.log(a); // undefined
var a = 10;
console.log(a); // 10
var a = 20; // 这个声明被忽略,但赋值有效
console.log(a); // 20

解析:

  1. 创建阶段: 引擎扫描到var a = 10;,在变量环境的环境记录中创建a并初始化为undefined。当扫描到var a = 20;时,发现a已经存在,所以忽略这个声明。最终环境记录是{ a: undefined }
  2. 执行阶段:
    • console.log(a);:打印undefined
    • a = 10;a被赋值为10
    • console.log(a);:打印10
    • a = 20;a被赋值为20
    • console.log(a);:打印20

四、 function 声明的提升:声明和定义都被提升

函数声明(function funcName() {})的提升行为比var变量更彻底。在执行上下文的创建阶段,JavaScript引擎会:

  1. 将函数声明的标识符(函数名)添加到变量环境的环境记录中。
  2. 不仅为函数分配内存,还会将整个函数定义(函数体)存储起来,并将其绑定到对应的标识符上。

这意味着,函数声明在任何代码执行之前,就已经完全可用了,包括它的函数体。

sayHello(); // 输出: Hello from sayHello!

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

sayHello(); // 输出: Hello from sayHello!

解析:

  1. 创建阶段:
    • JavaScript引擎扫描全局执行上下文。
    • 它发现function sayHello() { ... }这行代码。
    • 引擎在全局环境的变量环境(同时也是词法环境)的环境记录中创建sayHello这个绑定。
    • sayHello被直接初始化为指向该函数对象的引用。
    • 此时,环境记录大致是这样:{ sayHello: <func sayHello> }
  2. 执行阶段:
    • sayHello();:此时sayHello已经是一个完全可用的函数,可以直接调用,打印"Hello from sayHello!"
    • sayHello();:再次调用,同样打印"Hello from sayHello!"

等价于(但不是真正发生的代码移动):

function sayHello() { // 整个函数声明和定义被提升
    console.log("Hello from sayHello!");
}

sayHello(); // 输出: Hello from sayHello!
sayHello(); // 输出: Hello from sayHello!

五、 functionvar 的提升优先级

当一个作用域内同时存在同名的var变量声明和function声明时,函数声明的优先级更高。在执行上下文的创建阶段,如果遇到同名的情况:

  1. 函数声明会首先被处理,并初始化为函数对象。
  2. 当处理var声明时,如果发现同名标识符已经存在(并且是函数),var声明会被忽略。 但如果后续有对该var变量的赋值操作,那赋值会覆盖之前的函数引用。

看一个例子:

console.log(conflictingName); // 输出: [Function: conflictingName] (Node.js) 或 ƒ conflictingName() {} (浏览器)
conflictingName();          // 输出: Called the function!

var conflictingName = "I am a string";

console.log(conflictingName); // 输出: I am a string
conflictingName();          // TypeError: conflictingName is not a function

function conflictingName() {
    console.log("Called the function!");
}

console.log(conflictingName); // 输出: I am a string

解析:

  1. 创建阶段:
    • 引擎扫描到function conflictingName() { ... }。在变量环境的环境记录中,conflictingName被绑定到函数对象。
    • 接着扫描到var conflictingName = "I am a string";。由于conflictingName已经存在且是一个函数,var conflictingName这个声明被忽略(但不会覆盖函数),conflictingName仍然指向函数。
    • 此时环境记录大致是:{ conflictingName: <func conflictingName> }
  2. 执行阶段:
    • console.log(conflictingName);:打印函数对象本身。
    • conflictingName();:调用函数,打印"Called the function!"
    • var conflictingName = "I am a string";:这行代码实际上是conflictingName = "I am a string";。字符串值覆盖了之前的函数引用。
    • console.log(conflictingName);:打印"I am a string"
    • conflictingName();:尝试将字符串当作函数调用,导致TypeError
    • function conflictingName() { ... }:这行已经被处理过了,在执行阶段没有效果。
    • console.log(conflictingName);:仍然打印"I am a string"

这个例子清楚地表明,函数声明在创建阶段获得了更高的优先级,它会在var声明之前完成初始化。

六、 letconst:块级作用域与暂时性死区 (Temporal Dead Zone – TDZ)

ES6引入的letconst关键字为JavaScript带来了块级作用域(Block Scope)。它们与var的提升行为有着根本性的不同。

它们同样存在“提升”现象,但方式不同:

letconst声明的变量也会在执行上下文的创建阶段被处理,并被添加到其所在块级作用域的词法环境的环境记录中。然而,与var不同的是,它们不会被初始化为undefined。它们会处于一种“未初始化”(uninitialized)的状态。

从变量声明到其初始化之间的这段时间,我们称之为暂时性死区(Temporal Dead Zone – TDZ)。在这段时间内访问letconst声明的变量,会抛出ReferenceError

让我们看代码:

console.log(myLetVar); // ReferenceError: Cannot access 'myLetVar' before initialization
let myLetVar = "I am a let variable";
console.log(myLetVar); // I am a let variable

console.log(myConstVar); // ReferenceError: Cannot access 'myConstVar' before initialization
const myConstVar = "I am a const variable";
console.log(myConstVar); // I am a const variable

解析:

  1. 创建阶段:
    • 引擎扫描到let myLetVar = "I am a let variable";。在当前作用域的词法环境的声明式环境记录中,myLetVar被创建,但其状态是“未初始化”。
    • 引擎扫描到const myConstVar = "I am a const variable";。类似地,myConstVar在词法环境的声明式环境记录中被创建,状态也是“未初始化”。
    • 此时,环境记录大致是:{ myLetVar: <uninitialized>, myConstVar: <uninitialized> }
  2. 执行阶段:
    • console.log(myLetVar);:尝试访问myLetVar。由于myLetVar处于TDZ中(未初始化),引擎抛出ReferenceError
    • let myLetVar = "I am a let variable";:这行代码执行时,myLetVar被初始化并赋值。它离开了TDZ。
    • console.log(myLetVar);:打印"I am a let variable"
    • console.log(myConstVar);:尝试访问myConstVar。由于myConstVar处于TDZ中,引擎抛出ReferenceError
    • const myConstVar = "I am a const variable";:这行代码执行时,myConstVar被初始化并赋值。它离开了TDZ。
    • console.log(myConstVar);:打印"I am a const variable"

typeof 操作符与 TDZ:

即使是typeof操作符,在TDZ中访问letconst变量也会报错:

console.log(typeof myLetVar); // ReferenceError: Cannot access 'myLetVar' before initialization
let myLetVar;

而对于var变量,typeof会返回"undefined"

console.log(typeof myVar); // undefined
var myVar;

这进一步强调了let/constvar在提升行为上的本质区别。let/const的“提升”更像是“知道其存在但不允许访问”,而var是“知道其存在且可访问但值为undefined”。

块级作用域的影响:

letconst引入的块级作用域意味着它们的提升行为仅限于其所在的块。

{
    console.log(blockVar); // ReferenceError: Cannot access 'blockVar' before initialization
    let blockVar = "Inside block";
    console.log(blockVar); // Inside block
}
console.log(typeof blockVar); // undefined (在块外部,blockVar 未定义,不是 ReferenceError)

解析:

  1. 全局执行上下文创建阶段,blockVar不在全局环境记录中。
  2. 执行到{ ... }块时,会创建一个新的词法环境来管理这个块的作用域。
  3. 在这个块级词法环境的创建阶段,blockVar被添加到其声明式环境记录中,并处于“未初始化”状态。
  4. console.log(blockVar);:在TDZ中访问,抛出ReferenceError
  5. let blockVar = "Inside block";blockVar被初始化。
  6. console.log(blockVar);:打印"Inside block"
  7. 块结束,块级词法环境被销毁。
  8. console.log(typeof blockVar);:在全局作用域中,blockVar从未被声明,因此typeof返回"undefined",而不是ReferenceError(因为blockVar根本不存在于任何可访问的环境记录中,不是处于TDZ)。

总结 let / constvar 的区别:

特性 var let / const
提升行为 声明被提升,并初始化为 undefined 声明被提升,但处于“未初始化”状态(TDZ)
初始化时机 创建阶段 实际声明语句执行时
作用域 函数作用域或全局作用域 块级作用域
重复声明 允许,但后续声明被忽略 不允许,会抛出 SyntaxError
访问未初始化 得到 undefined 抛出 ReferenceError
全局对象属性 在全局作用域声明时,会成为 window 的属性 不会成为 window 的属性

七、 函数表达式(Function Expressions)的提升

函数表达式(Function Expressions)与函数声明(Function Declarations)的行为不同。函数表达式的本质是把一个函数赋值给一个变量。因此,它们的提升行为取决于用来声明变量的关键字(varletconst)。

使用 var 声明的函数表达式:

console.log(myFuncExpr); // undefined
// myFuncExpr();           // TypeError: myFuncExpr is not a function

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

myFuncExpr(); // Hello from function expression!

解析:

  1. 创建阶段: 引擎扫描到var myFuncExpr = function() { ... };myFuncExpr被添加到变量环境的环境记录中,并初始化为undefined
  2. 执行阶段:
    • console.log(myFuncExpr);:打印undefined
    • myFuncExpr();:尝试调用undefined,抛出TypeError
    • myFuncExpr = function() { ... };:函数对象被创建并赋值给myFuncExpr
    • myFuncExpr();:调用函数,打印"Hello from function expression!"

这与普通的var变量行为完全一致。

使用 letconst 声明的函数表达式:

// console.log(myLetFuncExpr); // ReferenceError: Cannot access 'myLetFuncExpr' before initialization
let myLetFuncExpr = function() {
    console.log("Hello from let function expression!");
};
myLetFuncExpr(); // Hello from let function expression!

// console.log(myConstFuncExpr); // ReferenceError: Cannot access 'myConstFuncExpr' before initialization
const myConstFuncExpr = () => { // 箭头函数也是函数表达式
    console.log("Hello from const arrow function expression!");
};
myConstFuncExpr(); // Hello from const arrow function expression!

解析:

  1. 创建阶段: myLetFuncExprmyConstFuncExpr被添加到词法环境的声明式环境记录中,并处于“未初始化”状态(TDZ)。
  2. 执行阶段: 访问它们都会触发ReferenceError,直到它们各自的声明语句被执行并完成初始化。

命名函数表达式(Named Function Expressions):

命名函数表达式的名称只在函数内部可见,外部作用域无法直接访问。其外部变量的提升行为依然取决于声明方式。

// console.log(outerName); // ReferenceError: Cannot access 'outerName' before initialization
let outerName = function innerName() {
    console.log("Inside innerName:", typeof innerName); // Inside innerName: function
};

// innerName(); // ReferenceError: innerName is not defined

outerName(); // Inside innerName: function

解析:

  • outerName遵循let的TDZ规则。
  • innerName这个名称只在function() { ... }这个函数体内部作为局部变量存在,不参与外部作用域的提升。因此,在函数外部尝试访问innerName会得到ReferenceError

八、 实际开发中的启示与最佳实践

理解变量提升的机制,尤其是从执行上下文的创建时序角度,对我们编写健壮、可维护的JavaScript代码至关重要。

  1. 避免 var 带来的混乱:
    var的函数作用域和提升行为(初始化为undefined)是许多初学者遇到意外行为的根源。它可能导致变量在声明前被访问却不报错,或者在循环中创建闭包时出现问题。

    for (var i = 0; i < 3; i++) {
        setTimeout(function() {
            console.log(i); // 总是输出 3, 3, 3
        }, 100);
    }
    // 这是因为 setTimeout 回调执行时,循环已经结束,i 的最终值是 3。
    // 如果使用 let,i 会在每次迭代中创建一个新的块级作用域。
  2. 拥抱 letconst
    letconst提供了块级作用域和更严格的提升规则(TDZ),这使得代码的行为更加可预测,减少了因变量提升而导致的bug。它们鼓励开发者在变量使用前明确声明,从而提高代码可读性。

    • 优先使用 const 如果变量在初始化后不会被重新赋值,就使用const。这有助于代码意图的表达,并防止意外的修改。
    • 使用 let 如果变量需要被重新赋值,则使用let
  3. 函数声明与函数表达式的选择:

    • 函数声明: 通常用于定义独立的、全局可用的工具函数。由于它们完全提升,你可以在文件中的任何位置调用它们,这使得代码组织更加灵活(但建议还是按顺序放置)。
    • 函数表达式: 常用于以下场景:
      • 作为回调函数(例如setTimeout、事件监听器)。
      • 创建立即执行函数表达式(IIFE)。
      • 闭包。
      • 当函数逻辑依赖于某些条件或变量,需要在特定时机创建时。
  4. 一致性与可读性:
    无论选择哪种声明方式,都应保持代码风格的一致性。为了提高代码的可读性,即使理解了提升机制,也建议遵循先声明后使用的原则。这符合大多数编程语言的习惯,能让阅读者更容易理解代码的逻辑流。

  5. 使用 ESLint 等工具:
    现代的JavaScript开发工具,如ESLint,可以帮助我们检测并强制执行编码规范,包括避免在声明前使用let/const变量,或对var的使用提出警告。

九、 结语

通过对JavaScript执行上下文初始化阶段的深入剖析,我们理解了变量提升并非代码的物理移动,而是在引擎扫描代码时,就已经为函数和变量(或其标识符)在内存中预留了位置。

  • var变量被提升并初始化为undefined
  • 函数声明被提升并初始化为完整的函数对象。
  • letconst变量同样被提升,但它们进入了“暂时性死区”,在声明语句执行前无法访问。

掌握这些核心概念,能帮助我们更清晰地理解JavaScript的作用域、生命周期以及其独特的运行时行为,从而编写出更健壮、更可预测的JavaScript代码。感谢大家的聆听!

发表回复

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