彻底搞懂 JavaScript 变量提升(Hoisting)—— 从现象到底层原理

17 阅读11分钟

面试必问、工作必踩的 JS 经典话题,一篇带你从现象深入到 V8 引擎的执行原理。


目录

  1. 先看现象:代码为什么没有报错?
  2. JS 代码的两个阶段:编译阶段 vs 执行阶段
  3. var 变量提升
  4. 函数提升:声明 vs 表达式
  5. 函数优先:一等公民的特权
  6. 同名函数:后者覆盖前者
  7. let / const 有没有提升?—— 暂时性死区(TDZ)
  8. 变量环境 vs 词法环境
  9. 模拟变量提升:引擎到底做了什么?
  10. 总结:一图胜千言

1. 先看现象:代码为什么没有报错?

按照传统编程思维,代码是一行一行顺序执行的。来看两个例子:

Case 1:变量完全没有声明

<script>
    showName();          // 函数还没定义,能调用吗?
    console.log(myName); // myName 根本没声明,会怎样?

    function showName() {
        console.log("函数 showName() 被执行");
    }
</script>

运行结果:

函数 showName() 被执行
 ReferenceError: myName is not defined

函数照常执行了,但 myName 因为根本没有声明(没有 var / let / const),抛出了 ReferenceError

这里已经有第一个反直觉现象:函数在声明之前就能被调用

Case 2:用 var 声明了变量

现在给 myName 加上 var 声明:

showName();
console.log(myName);     // myName 用 var 声明了
console.log(add);        // add 也用 var 声明了

var myName = "极客时间";

// 传统函数声明
function showName() {
    console.log("函数 showName 被执行了");
}

// 函数表达式
var add = function (x, y) {
    return x + y;
};

输出结果:

函数 showName 被执行了
undefined
undefined

三个调用都在声明之前,但一个都没报错!

  • showName() 正常执行
  • myName 输出 undefined
  • add 也输出 undefined

关键对比

情况代码特征结果
完全未声明console.log(x) 且没有 var/let/constReferenceError
var 声明了console.log(x) 且后面有 var xundefined(变量提升)
函数声明foo() 且后面有 function foo()正常执行(函数提升)

核心矛盾:只要用 var 声明过(哪怕声明写在调用之后),变量就不会报错,值为 undefined;函数声明更是完整可用。这说明 JS 代码并不只是一行一行执行的——在执行之前,还有一个"准备工作"的阶段。


2. JS 代码的两个阶段:编译阶段 vs 执行阶段

JavaScript 虽然是脚本语言、弱类型、动态的,但它在执行前也有一个极短的 "编译阶段"。这个阶段发生在代码运行前的那一刹那。

源代码
   │
   ▼
┌──────────────┐
│  编译阶段     │  → 生成 执行上下文(Execution Context)+ 可执行代码
│  (变量提升)  │    包括:变量环境(Variable Environment)
│              │          词法环境(Lexical Environment)
└──────────────┘
   │
   ▼
┌──────────────┐
│  执行阶段     │  → 按顺序逐行执行代码
└──────────────┘

关键认知:"变量提升"并不是物理上把代码挪到了最前面,而是在编译阶段,JS 引擎把变量和函数的声明提前放入内存中。代码的位置没有变,但内存已经提前分配好了。


3. var 变量提升

3.1 什么是 var 提升?

var 声明的变量,其声明部分会在编译阶段被提升到作用域顶部,并初始化为 undefined

// ↓↓↓ 编译阶段,引擎相当于做了这件事 ↓↓↓
var myName = undefined;

// ↑↑↑ 编译阶段结束,执行阶段开始 ↑↑↑

console.log(myName);   // undefined(不是报错!)
myName = "极客时间";    // 赋值操作留在原地执行

3.2 拆解:声明 vs 赋值

var myName = "极客时间";

这一行代码实际上由两部分组成:

阶段操作做什么
编译阶段var myName声明变量,分配内存,初始化为 undefined
执行阶段myName = "极客时间"赋值,把字符串写入已分配的内存空间

记牢:提升的只是声明,不是赋值。赋值老老实实待在原地,轮到它才执行。


4. 函数提升:声明 vs 表达式

4.1 函数声明 —— 完整提升

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

这种写法是完整的函数声明,没有涉及赋值操作。编译阶段会把整个函数体都提升,包括函数名和实现代码。所以:

foo();  //  "foo" —— 正常执行!

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

4.2 函数表达式 —— 只有变量提升

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

这里面包含两步:

阶段操作
编译阶段var bar = undefined;(只是变量提升,不是函数提升)
执行阶段bar = function(){ console.log("bar"); }(赋值发生在原地)

所以:

bar();  //  TypeError: bar is not a function

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

bar 在执行时还是 undefined,当然不能被调用。

4.3 对比总结

//  函数声明 —— 整个函数提升,可以在声明前调用
showName();                          // "函数 showName 被执行了"
function showName() {
    console.log("函数 showName 被执行了");
}

//  函数表达式 —— 只有变量名提升(值为 undefined),不能在赋值前调用
add(1, 2);                           // TypeError: add is not a function
var add = function (x, y) {
    return x + y;
};

5. 函数优先:一等公民的特权

同名的变量声明和函数声明同时存在,谁说了算?

showName();  // 输出什么?

var showName = function () {
    console.log(2);
};

function showName() {
    console.log(1);
}

答案:输出 1

规则:变量提升时,函数声明优先于变量声明。函数是一等公民,同名的 var 声明会被忽略。

编译阶段的处理顺序:

① 先处理函数声明 → showName = function(){ console.log(1) }
② 再处理 var 声明   → 发现 showName 已存在,跳过(不会覆盖为 undefined)
③ 执行阶段:
   showName()        → 调用当前值(函数①),输出 1
   showName = ...    → 赋值(函数表达式),覆盖为 function(){ console.log(2) }

6. 同名函数:后者覆盖前者

如果定义了两个同名的函数声明呢?

function showName() {
    console.log("Niko");
}
showName();  // "Monesy"

function showName() {
    console.log("Monesy");
}
showName();  // "Monesy"

输出两次都是 "Monesy"

规则:同名的函数声明,后面的会覆盖前面的。编译阶段处理完所有声明后,生效的是最后一个。


7. let / const 有没有提升?—— 暂时性死区(TDZ)

7.1 先看现象

console.log(myName);       //  ReferenceError: Cannot access 'myName' before initialization
let myName = "极客时间";

如果用 var,这里输出 undefined;用 let,直接报错。

7.2 let 到底提不提升?

答案是:提升了,但和 var 不一样。

//  错误理解:let 没有提升
//  正确理解:let 的声明在编译阶段也被提升了(内存空间在词法环境中分配),
//    但没有像 var 那样初始化为 undefined,所以在声明前不能访问。

对比说明:

特性varlet / const
编译阶段是否分配内存?
初始化为 undefined否(未初始化)
声明前访问?可以(值为 undefined报错 ReferenceError
存储位置变量环境(Variable Environment)词法环境(Lexical Environment)

暂时性死区(Temporal Dead Zone, TDZ): 从代码块开始到 let/const 声明语句执行之前,变量处于"未初始化"状态,这段区域就是 TDZ。在这个区域内访问变量会抛出 ReferenceError

7.3 一张图理解

代码块开始
   │
   ├── TDZ 开始(let 在编译阶段已分配空间,但未初始化)
   │      │
   │      ├── console.log(x)  ←  在 TDZ 内,报错!
   │      │
   ├── let x = 10;  ← 声明执行,TDZ 结束
   │
   ├── console.log(x)  ←  正常运行
   │
代码块结束

8. 变量环境 vs 词法环境

这是理解 varlet 差异的关键:

执行上下文(Execution Context)
├── 变量环境(Variable Environment)
│   └── var 声明的变量住这里
│   └── 编译阶段初始化为 undefined
│   └── 可以在声明前访问
│
├── 词法环境(Lexical Environment)
│   └── let / const 声明的变量住这里
│   └── 编译阶段分配空间但不初始化
│   └── 在声明前处于 TDZ,不可访问
│
└── 外部环境引用(Outer Reference

一句话总结let 变量也提升了(内存提前分配),但它"不跟 var 同流合污"——没有初始化提升,老老实实待在 TDZ 里,写代码时你必须先声明再使用。


9. 模拟变量提升:引擎到底做了什么?

9.1 编译阶段(引擎的"准备工作")

把下面这段代码交给 JS 引擎:

// === 源代码 ===
showName();
console.log(myname);
var myname = "极客时间";
function showName() {
    console.log("函数 showName 被执行了");
}

编译阶段结束后,相当于变成了:

// === 编译阶段产物:声明全部提升 ===
var myname = undefined;
function showName() {
    console.log("函数 showName 被执行了");
}

9.2 执行阶段(按顺序跑)

// === 执行阶段开始,逐行执行 ===
showName();              // → "函数 showName 被执行了"
console.log(myname);     // → undefined
myname = "极客时间";     // → 赋值

9.3 完整流程图解

输入源代码
    │
    ▼
┌─────────────────────────────────────────────┐
│  编译阶段(极短的一刹那)                      │
│                                             │
│  1. 扫描所有 var 声明 → 放入变量环境          │
│     初始化为 undefined                       │
│  2. 扫描所有 function 声明 → 放入变量环境       │
│     完整存储函数体                            │
│  3. 扫描所有 let/const 声明 → 放入词法环境     │
│     分配空间但 不 初始化(TDZ)               │
│                                             │
│  产出:执行上下文 + 可执行代码                  │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│  执行阶段(代码一行一行跑)                     │
│                                             │
│  - 按书写顺序执行                            │
│  - 变量已经分配好内存,只是值可能还是 undefined │
│  - let/constTDZ 内不可访问                │
│  - 赋值语句在这一阶段才生效                    │
└─────────────────────────────────────────────┘

10. 总结:一图胜千言

核心结论

结论说明
JS 代码不是一行一行执行的有编译阶段和执行阶段两个过程
变量提升发生在编译阶段本质是内存的提前分配,不是代码位置的物理移动
var 声明提升 + 初始化为 undefined声明前可访问,值为 undefined
函数声明完整提升整个函数体都被提升,可在声明前调用
函数表达式只提升变量名和普通 var 一样,声明前值为 undefined
函数优先于变量同名时函数声明覆盖 var 声明
同名函数后者覆盖前者最终生效的是最后声明的那个函数
let/const 提升了但没初始化存在 TDZ,声明前访问报 ReferenceError
var 在变量环境,let/const 在词法环境这是二者行为差异的底层原因
未声明的变量直接报错ReferenceError: x is not defined,这和提升无关

检验清单

能回答出下面这些问题,说明你真的掌握了:

  • 完全没声明的变量和 var 声明的变量,在声明前访问有什么区别?
  • 函数声明和函数表达式的提升有什么区别?
  • showName() 同时有函数声明和 var 声明,调用的是哪个?
  • let 到底有没有提升?TDZ 是什么意思?
  • 变量环境和词法环境分别存放什么?
  • "变量提升是内存提前分配" 怎么理解?

参考答案

1. 完全没声明的变量和 var 声明的变量,在声明前访问有什么区别?
  • 完全没声明:抛出 ReferenceError: x is not defined。JS 引擎在编译阶段没有为它分配任何内存,执行阶段找不到这个标识符,直接报错。
  • var 声明了:输出 undefined,不报错。编译阶段已经为它分配内存并初始化为 undefined,执行阶段访问时变量已经存在,只是还没被赋值。
2. 函数声明和函数表达式的提升有什么区别?
  • 函数声明function foo() {}):整个函数体都被提升,在声明之前调用可以正常执行。
  • 函数表达式var foo = function() {}):只有变量名 foo 被提升(值为 undefined),函数体本身不会提升。在赋值之前调用会抛 TypeError: foo is not a function
3. showName() 同时有函数声明和 var 声明,调用的是哪个?

调用的函数声明的那个。编译阶段函数声明优先于 var 声明,同名的 var 声明会被忽略(不会把函数覆盖为 undefined)。所以调用时 showName 指向的是函数对象,输出 1

4. let 到底有没有提升?TDZ 是什么意思?
  • let 有提升:编译阶段 JS 引擎在词法环境中为 let 变量分配了内存空间,但不会初始化为 undefined
  • TDZ(暂时性死区):从代码块开始到 let 声明语句执行之前的这段区域。变量已经分配了空间但处于"未初始化"状态,在 TDZ 内访问变量会抛出 ReferenceError。直到执行到声明语句,变量才完成初始化,TDZ 结束。
5. 变量环境和词法环境分别存放什么?
  • 变量环境(Variable Environment):存放 var 声明的变量和函数声明,编译阶段初始化为 undefined,可以在声明前访问。
  • 词法环境(Lexical Environment):存放 let / const 声明的变量,编译阶段分配空间但不初始化,在声明前处于 TDZ,不可访问。
6. "变量提升是内存提前分配" 怎么理解?

变量提升并不是代码被物理移动到了顶部,而是在编译阶段,JS 引擎扫描代码中的声明语句,提前为变量和函数分配内存空间。到了执行阶段,代码按顺序执行时,这些变量已经存在于内存中了,所以可以访问——只是 var 的值为 undefined、let 还未初始化。代码的位置没变,变的是内存已经在编译阶段准备好了