深入理解 JavaScript 变量提升(Hoisting)—— 从现象到原理

0 阅读6分钟

JavaScript 中最令人困惑的特性之一:为什么在声明之前使用变量不会报错,而是返回 undefined?为什么函数却可以正常调用?本文带你从现象出发,逐层深入 JavaScript 引擎的内部工作机制。


目录

  1. 从一个反直觉的现象说起
  2. 变量提升是什么
  3. 函数提升 —— 一等公民的待遇
  4. 声明 vs 赋值:拆开来看
  5. 编译阶段与执行阶段
  6. 执行上下文:代码运行的幕后环境
  7. 变量环境 vs 词法环境
  8. let / const 与暂时性死区
  9. 总结与最佳实践

1. 从一个反直觉的现象说起

请看下面这段代码,猜猜它会输出什么?

showName();
console.log(myName);
var myName = '极客时间';

function showName() {
    console.log('函数 showName 被执行了');
}

直觉告诉我们:

  • 第 1 行:showName() —— 函数还没定义呢,应该报错!
  • 第 2 行:console.log(myName) —— 变量还没声明呢,也应该报错!

然而实际运行结果是:

函数 showName 被执行了
undefined

函数正常执行了,变量也没有报错,只是值为 undefined。为什么?

┌─────────────────────────────────────────────────────────────────┐
│                    直觉预期 vs 实际结果                          │
├─────────────────────┬───────────────────┬───────────────────────┤
│        代码          │     直觉预期      │      实际结果         │
├─────────────────────┼───────────────────┼───────────────────────┤
│ showName()          │    ❌ 报错        │   ✅ 正常执行         │
│ console.log(myName) │    ❌ 报错        │   ⚠️ undefined       │
└─────────────────────┴───────────────────┴───────────────────────┘

这说明了一个重要事实:JavaScript 代码并不是一行一行直接执行的。


2. 变量提升是什么

2.1 定义

变量提升(Hoisting) 是指在 JavaScript 代码执行过程中,JavaScript 引擎(如 Chrome V8)把变量声明部分函数声明部分提前到作用域顶部进行处理的行为。变量被提升后,会被赋予默认值 undefined

2.2 一个常见的误解

很多人以为"变量提升"就是把代码在物理层面移动到最前面。比如:

// 原始代码
console.log(myName);
var myName = '极客时间';

被误解为:

// ❌ 错误的理解:物理移动
var myName;
console.log(myName);
myName = '极客时间';

实际上,变量和函数声明在代码中的位置从未改变。 真正发生的是:在编译阶段,JavaScript 引擎将这些声明信息放入内存之中,为执行阶段做好准备。

2.3 图解变量提升的本质

compilation-execution.svg


3. 函数提升 —— 一等公民的待遇

3.1 函数声明的提升

函数声明会被整体提升——不仅提升了声明,连函数体也一起提升了:

// 可以在声明之前调用!
foo(); // 输出:'foo'

function foo() {
    console.log('foo');
}

编译阶段的内存模型:

┌─────────────────────────────────┐
│      VariableEnvironment        │
│  ┌───────────────────────────┐  │
│  │  foo → function {         │  │
│  │    console.log('foo')     │  │
│  │  }                        │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

3.2 函数表达式 vs 函数声明

这是初学者最容易犯错的地方。函数表达式不会整体提升:

// ❌ 函数表达式 —— 报错!
bar(); // TypeError: bar is not a function

var bar = function() {
    console.log('bar');
};

为什么?因为 var bar 被提升了,但赋值 = function() {...} 没有。编译后等效于:

var bar = undefined;    // 编译阶段:声明提升,值为 undefined

bar();                  // 执行阶段:bar 是 undefined,无法调用!报错

bar = function() {      // 执行阶段:赋值
    console.log('bar');
};

3.3 函数 vs 变量 —— 谁的优先级更高?

当函数和变量同名时,函数提升优先于变量提升

console.log(typeof foo); // "function"

var foo = 'hello';

function foo() {
    return 'world';
}

4. 声明 vs 赋值:拆开来看

理解变量提升的关键,是把一行代码拆成两部分:

     var myName = '极客时间';
     │          │
     │          └── 赋值部分(执行阶段处理)
     │
     └── 声明部分(编译阶段处理)

我们可以用代码来模拟变量提升后代码的逻辑形态:

编译阶段产物 —— 声明部分(4.js)

// 编译阶段:变量和函数声明被放入内存
var myname = undefined;

function showName() {
    console.log('showName 被执行了');
}

执行阶段产物 —— 可执行代码(5.js)

// 执行阶段:按顺序逐行执行
showName();            // → 'showName 被执行了'
console.log(myname);   // → undefined
myname = '极客时间';   // → 赋值

完整模拟效果(3.js):

// 把编译阶段的声明和执行阶段的代码组合起来看
var myname = undefined;
function showName() {
    console.log('showName 被执行了');
}
// ─────── 以上是编译阶段准备 ───────
// ─────── 以下是执行阶段运行 ───────
showName();
console.log(myname);
myname = '极客时间';

5. 编译阶段与执行阶段

5.1 JavaScript 没有独立的编译阶段?

JavaScript 是脚本语言弱类型动态的,它不像 Java/C++ 那样有一个独立的、耗时的编译过程。但它在代码运行前的一瞬间会进行编译。

┌──────────────────────────────────────────────────────┐
│                   JS 代码执行全流程                    │
│                                                      │
│   源代码                                               │
│     │                                                │
│     ▼                                                │
│   ┌──────────┐      ┌─────────────────┐              │
│   │  编译阶段  │ ──► │ 执行上下文       │              │
│   │ (一瞬间) │      │ + 可执行代码     │              │
│   └──────────┘      └─────────┬───────┘              │
│                               │                      │
│                               ▼                      │
│                          ┌──────────┐                │
│                          │  执行阶段  │               │
│                          │ (逐行执行)│               │
│                          └──────────┘                │
│                                                      │
└──────────────────────────────────────────────────────┘

5.2 编译阶段做了什么

输入一段 JavaScript 代码,经过编译后会生成两部分:

产物说明
执行上下文 (Execution Context)代码运行的环境,包含变量环境、词法环境、作用域链等
可执行代码编译后的字节码,供引擎逐条执行

6. 执行上下文:代码运行的幕后环境

6.1 什么是执行上下文

执行上下文是 JavaScript 执行一段代码时的运行环境。每当你调用一个函数,JavaScript 引擎就会创建一个新的执行上下文,并将其压入调用栈

6.2 执行上下文内部结构

execution-context.svg


7. 变量环境 vs 词法环境

这是理解 varlet/const 行为差异的关键。

7.1 变量环境 (VariableEnvironment)

  • 存放 var 声明的变量和函数声明
  • 有变量提升:在声明之前使用,返回 undefined
  • 在编译阶段完成内存分配和初始化(初始值为 undefined

7.2 词法环境 (LexicalEnvironment)

  • 存放 letconst 声明的变量
  • 没有变量提升(或者说,提升但不初始化):
    • 在编译阶段,变量被创建但没有初始化
    • 在声明之前使用会报错(而非返回 undefined

var-vs-let.svg


8. let / const 与暂时性死区

8.1 什么是暂时性死区

let/const 声明的变量在词法环境中,从代码块开始到变量声明之前的这段区域,称为暂时性死区(Temporal Dead Zone,TDZ)

// ─── TDZ 开始 ───
console.log(myname);  // ❌ ReferenceError: Cannot access 'myname' before initialization
// ─── TDZ 结束 ───
let myname = '极客时间';
// 可以正常使用了
console.log(myname);  // ✅ '极客时间'

8.2 var 提升 vs let 不提升

// var —— 在变量环境中,声明前可用(值为 undefined)
console.log(a); // undefined
var a = 1;

// let —— 在词法环境中,声明前不可用(暂时性死区)
console.log(b); // ReferenceError!
let b = 2;

tdz.svg

8.3 本质

变量提升的本质,是在编译阶段完成内存分配:

  • var 声明:编译阶段 → 分配内存 + 初始化为 undefined → 变量环境(VariableEnvironment)
  • let/const 声明:编译阶段 → 分配内存但不初始化 → 词法环境(LexicalEnvironment)→ 声明前使用 → 暂时性死区 → 报错

9. 总结与最佳实践

knowledge-map.svg

✅ 最佳实践

建议说明
优先使用 letconst避免 var 的提升行为带来的困惑
先声明,后使用让代码逻辑更清晰,更易维护
函数声明放在作用域顶部虽然可以后置,但前置更符合阅读习惯
理解而非依赖提升提升是编译机制,不应成为代码风格

📝 一句话记忆

变量提升发生在编译阶段,var 声明初始化为 undefined 放入变量环境;函数声明整体提升;let/const 放入词法环境且处于暂时性死区,声明前使用会报错。