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

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


目录

  1. 先看现象:代码为什么没有报错?
  2. [JS 代码的两个阶段:编译阶段 vs 执行阶段](#JS 代码的两个阶段:编译阶段 vs 执行阶段 "#2-js-%E4%BB%A3%E7%A0%81%E7%9A%84%E4%B8%A4%E4%B8%AA%E9%98%B6%E6%AE%B5%E7%BC%96%E8%AF%91%E9%98%B6%E6%AE%B5-vs-%E6%89%A7%E8%A1%8C%E9%98%B6%E6%AE%B5")
  3. [var 变量提升](#var 变量提升 "#3-var-%E5%8F%98%E9%87%8F%E6%8F%90%E5%8D%87")
  4. [函数提升:声明 vs 表达式](#函数提升:声明 vs 表达式 "#4-%E5%87%BD%E6%95%B0%E6%8F%90%E5%8D%87%E5%A3%B0%E6%98%8E-vs-%E8%A1%A8%E8%BE%BE%E5%BC%8F")
  5. 函数优先:一等公民的特权
  6. 同名函数:后者覆盖前者
  7. [let / const 有没有提升?------ 暂时性死区(TDZ)](#let / const 有没有提升?—— 暂时性死区(TDZ) "#7-let--const-%E6%9C%89%E6%B2%A1%E6%9C%89%E6%8F%90%E5%8D%87-%E6%9A%82%E6%97%B6%E6%80%A7%E6%AD%BB%E5%8C%BAtdz")
  8. [变量环境 vs 词法环境](#变量环境 vs 词法环境 "#8-%E5%8F%98%E9%87%8F%E7%8E%AF%E5%A2%83-vs-%E8%AF%8D%E6%B3%95%E7%8E%AF%E5%A2%83")
  9. 模拟变量提升:引擎到底做了什么?
  10. 总结:一图胜千言

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

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

Case 1:变量完全没有声明

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

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

运行结果:

scss 复制代码
函数 showName() 被执行
 ReferenceError: myName is not defined

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

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

Case 2:用 var 声明了变量

现在给 myName 加上 var 声明:

js 复制代码
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;
};

输出结果:

javascript 复制代码
函数 showName 被执行了
undefined
undefined

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

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

关键对比

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

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


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

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

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

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


3. var 变量提升

3.1 什么是 var 提升?

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

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

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

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

3.2 拆解:声明 vs 赋值

js 复制代码
var myName = "极客时间";

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

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

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


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

4.1 函数声明 ------ 完整提升

js 复制代码
function foo() {
    console.log("foo");
}

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

js 复制代码
foo();  //  "foo" ------ 正常执行!

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

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

js 复制代码
var bar = function () {
    console.log("bar");
};

这里面包含两步:

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

所以:

js 复制代码
bar();  //  TypeError: bar is not a function

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

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

4.3 对比总结

js 复制代码
//  函数声明 ------ 整个函数提升,可以在声明前调用
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. 函数优先:一等公民的特权

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

js 复制代码
showName();  // 输出什么?

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

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

答案:输出 1

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

编译阶段的处理顺序:

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

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

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

js 复制代码
function showName() {
    console.log("Niko");
}
showName();  // "Monesy"

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

输出两次都是 "Monesy"

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


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

7.1 先看现象

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

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

7.2 let 到底提不提升?

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

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

对比说明:

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

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

7.3 一张图理解

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

8. 变量环境 vs 词法环境

这是理解 varlet 差异的关键:

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

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


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

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

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

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

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

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

9.2 执行阶段(按顺序跑)

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

9.3 完整流程图解

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

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,执行阶段访问时变量已经存在,只是还没被赋值。
  1. 函数声明和函数表达式的提升有什么区别?
  • 函数声明function foo() {}):整个函数体都被提升,在声明之前调用可以正常执行。
  • 函数表达式var foo = function() {}):只有变量名 foo 被提升(值为 undefined),函数体本身不会提升。在赋值之前调用会抛 TypeError: foo is not a function
  1. showName() 同时有函数声明和 var 声明,调用的是哪个?

调用的函数声明 的那个。编译阶段函数声明优先于 var 声明 ,同名的 var 声明会被忽略(不会把函数覆盖为 undefined)。所以调用时 showName 指向的是函数对象,输出 1
4. let 到底有没有提升?TDZ 是什么意思?

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

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


相关推荐
零度晚风1 小时前
React 底层原理 & 新特性
前端
用户61848240219511 小时前
我受够了 Electron 的 IPC 样板代码,于是写了 electron-ipc-auto-import
前端
梦想的颜色1 小时前
TypeScript 完全指南(中):函数、接口、类与高级类型
前端·typescript
鹏多多1 小时前
OpenSpec+SDD规范驱动AI Agent开发项目实战指南
前端·vue.js·react.js
叶小树咯1 小时前
React 为什么不能像 Vue 那样 state.count++
前端·react.js
ricardo19731 小时前
防抖节流进阶 + requestAnimationFrame:滚动与输入场景的性能优化
前端·面试
wjj不想说话1 小时前
你项目里的 Pinia,可能已经成了第二个 localStorage
前端·vue.js
wuhen_n2 小时前
LangChain JS 入门:快速搭建前端 AI 开发环境
前端·langchain·ai编程
天蓝色的鱼鱼2 小时前
画1万个图形就卡成PPT?试试这款国产高性能2D引擎
前端·javascript