前言
在日常的 JavaScript 开发中,你是否遇到过这样的困惑:为什么在变量声明之前访问它不会报错,而是返回 undefined?为什么在函数定义之前调用它却能正常执行?这些看似 "反直觉" 的行为背后,隐藏着 JavaScript 引擎运行机制的核心秘密 ------变量提升(Hoisting) 。
本文将从现象出发,深入 JavaScript 引擎(Chrome V8)的执行原理,带你彻底搞懂变量提升的本质,以及 JS 代码的编译与执行流程。
一、从一个 "奇怪" 的例子说起
请看下面这段代码:
javascript
// 函数声明
showName();
console.log(myName);
console.log(add);
var myName = '极客时间';
// 传统的函数声明
function showName(){
console.log('函数showName被执行了');
}
// 匿名函数 / 函数表达式
var add = function(a, b){
return a + b;
}
执行结果:
javascript
函数showName被执行了
undefined
undefined
这段代码暴露了三个问题:
- 函数
showName在定义之前被调用,却没有报错,而是正常执行了。 - 变量
myName在声明之前被访问,没有报错,但值是undefined,而不是'极客时间'。 - 变量
add在赋值之前被访问,同样是undefined,但它被赋值为一个函数,却无法在赋值前调用。
按照传统的编程思维,代码应该是一行一行顺序执行 的。如果真是一行一行执行,那么第 1 行 showName() 和第 2 行 console.log(myName) 都应该报错 ReferenceError,因为此时它们还没有被定义。
核心现象:JS 代码并非单纯自上而下逐行执行,声明类代码会在编译阶段提前处理,这是变量提升的核心前提。
但事实并非如此。
但事实并非如此。
二、传统认知 vs 实际情况
2.1 传统认知:代码按顺序执行
大多数开发者初学 JS 时都认为:JavaScript 代码就是从上到下一行一行执行的。按照这个逻辑:
showName()调用时,函数尚未定义 → 应该报错console.log(myName)访问时,变量尚未声明 → 应该报错
但实际运行结果推翻了这一认知。
2.2 实际表现总结
| 场景 | 表现 | 是否报错 |
|---|---|---|
| 使用未声明的变量 | 报错 ReferenceError |
✅ 报错 |
| 在变量定义之前使用它 | 不报错,值为 undefined |
❌ 不报错 |
| 在函数定义之前调用它 | 不报错,函数正常执行 | ❌ 不报错 |
后两种情况说明:JavaScript 代码并不是简单的一行一行执行的。
三、JS 代码执行的两阶段模型
3.1 先编译,后执行
JavaScript 虽然是脚本语言(弱类型、动态),没有像 Java/C++ 那样独立的、漫长的 "编译期",但它在代码执行前的瞬间 ,仍然会经历一个短暂的编译阶段。
整个过程分为两个阶段:
- 编译阶段(Compilation Phase)
- 执行阶段(Execution Phase)
📌 图 1:JavaScript 代码执行的两阶段流程
3.2 编译阶段做了什么?
输入一段 JavaScript 代码,经过编译后,会生成两部分内容:
- 执行上下文(Execution Context) :JS 执行一段代码的运行环境
- 可执行代码(Executable Code) :编译后的字节码
核心重点 :变量提升不属于执行阶段的行为,所有变量、函数的提升动作,全部发生在编译阶段。
变量提升就是在编译阶段发生的。 在这个阶段,JavaScript 引擎会:
- 扫描整个代码,找到所有
var变量声明 - 扫描所有
function函数声明 - 将这些声明提升到作用域的顶部,并分配内存空间
具体来说:
提升规则(核心)
- var 变量 :仅提升声明,编译期初始化为
undefined,赋值留在原执行位置 - 函数声明 function:完整提升(声明+函数体),编译期直接赋值为函数对象
var声明的变量 :提升变量名,初始化为undefined- 函数声明
function:整体提升,包括函数体,初始化为函数对象本身 var声明的变量 :提升变量名,初始化为undefined- 函数声明
function:整体提升,包括函数体,初始化为函数对象本身
3.3 用代码模拟编译后的效果
原始代码经过编译阶段后,逻辑上等价于以下代码:
编译阶段后 ------ 变量环境部分:
javascript
// 变量提升部分的代码(编译阶段完成)
var myname = undefined;
function showName(){
console.log('函数showName被执行了');
}
执行阶段代码:
javascript
// 执行阶段的代码
showName();
console.log(myname);
myname = '极客时间';
将两者合并,完整的执行等价于:
javascript
var myname = undefined;
function showName() {
console.log('showName被执行了');
}
showName();
console.log(myname);
myname = '极客时间';
📌 图 2:变量提升前后的代码逻辑对比
这样一看,代码的行为就完全合理了 ------变量和函数的声明被 "移动" 到了代码的顶部 。但需要注意,这种 "移动" 只是逻辑上的理解方式,实际上代码的位置并没有改变,只是 JavaScript 引擎在编译阶段将声明放入了内存中。
关键纠正 :变量提升无物理位置移动,本质是编译阶段提前分配内存、注册标识符
四、变量提升的完整机制
4.1 什么是变量提升?
变量提升(Hoisting) 是指在 JavaScript 代码执行过程中,JavaScript 引擎(如 Chrome V8 引擎)把变量声明部分 和函数声明部分 提升到代码开头的 "行为"。变量提升后,会给变量设置默认值 undefined。
4.2 深入理解:不仅仅是 "移动代码"
很多教程说 "变量和函数声明会被物理移动到代码最前面"------ 这种说法并不准确。
实际情况是:
- 变量和函数声明在代码中的物理位置并没有改变
- 在编译阶段 ,JS 引擎将它们放入内存中
- 在执行阶段,JS 引擎可以直接从内存中访问这些变量和函数
对于 var 声明的变量来说,JS 引擎会将其拆分为声明 和赋值两个独立的步骤:声明阶段在编译期完成并被提升,赋值阶段则保留在原代码位置,在执行期完成。
📌 图 3:var 声明的拆分:声明提升,赋值留在原地
4.3 函数声明 vs 函数表达式的提升差异
这是一个非常重要的区别:
javascript
// 函数声明 ------ 整体提升
function showName(){
console.log('函数showName被执行了');
}
// 函数表达式 ------ 只有变量名提升,函数体不提升
var add = function(a, b){
return a + b;
}
高频易错核心:函数声明 vs 函数表达式
- 函数声明:整体提升,声明前可正常调用
- 函数表达式 :仅变量名提升(值为
undefined),函数体不提升,声明前调用报TypeError
- 函数声明
function showName(){}:整个函数(包括函数体)被提升,所以在声明前调用也可以正常执行。 - 函数表达式
var add = function(){}:只有变量名add被提升(值为undefined),函数体并没有被提升。所以在赋值前调用add()会报错TypeError: add is not a function。
📌 图 4:函数声明与函数表达式的提升差异
4.4 为什么函数是一等对象?
在 JavaScript 中,函数是一等公民(First-Class Object) :
- 函数可以被赋值给变量
- 函数可以作为参数传递给其他函数
- 函数可以作为其他函数的返回值
正因为函数的这种特性,函数声明在编译阶段被完整提升,包括其函数体代码。这使得我们在代码的任何位置都可以调用在后续位置声明的函数。
五、var 与 let/const 的提升差异 ------ 暂时性死区(TDZ)
5.1 let 和 const 也有提升吗?
有,但与 var 的行为不同。 来看这段代码:
重磅误区纠正 :let/const存在变量提升,并非不提升!区别是提升后不初始化,进入暂时性死区。
javascript
console.log(myName); // ReferenceError: Cannot access 'myName' before initialization
let myName = '极客时间';
5.2 三种声明的提升行为对比
| 声明方式 | 是否提升 | 初始化值 | 声明前访问 |
|---|---|---|---|
var |
✅ 提升 | undefined |
返回 undefined |
function |
✅ 提升(含函数体) | 函数对象 | 正常执行 |
let |
✅ 提升(但进入 TDZ) | 无(未初始化) | 报错 ReferenceError |
const |
✅ 提升(但进入 TDZ) | 无(未初始化) | 报错 ReferenceError |
5.3 什么是暂时性死区(Temporal Dead Zone, TDZ)?
let 和 const 声明的变量确实会被提升 ,但它们被提升后并不会被初始化 。从作用域顶部到变量实际声明位置之间的区域,被称为暂时性死区(TDZ) 。
TDZ 核心规则 :变量提升后未完成初始化,在声明语句执行前,该变量处于死区,任何访问都会直接报错 ,而非 undefined。
在 TDZ 内访问变量会抛出 ReferenceError: Cannot access 'variable' before initialization。
在 TDZ 内访问变量会抛出 ReferenceError: Cannot access 'variable' before initialization。
javascript
{
// TDZ 开始
console.log(x); // ❌ ReferenceError
// TDZ 继续
let x = 10; // TDZ 结束
console.log(x); // ✅ 10
}
5.4 变量环境 vs 词法环境
为什么 var 和 let/const 的行为不同?根本原因在于它们存储在不同的内存空间中:
- 变量环境(Variable Environment) :存储
var和function声明。在编译阶段完成内存分配并初始化为undefined。 - 词法环境(Lexical Environment) :存储
let和const声明。在编译阶段完成内存分配但不初始化,处于 TDZ 状态。
在执行代码时:
- 变量环境中的声明可以在声明前访问 (值为
undefined) - 词法环境中的声明不能在声明前访问(会报错)
变量提升的本质,就是在编译阶段完成内存分配的过程。
终极本质总结 : 1. 变量环境(var/function):编译期分配内存 + 自动初始化为 undefined/函数对象 2. 词法环境(let/const):编译期分配内存 + 不初始化 + 进入 TDZ
六、深入理解执行上下文(Execution Context)
6.1 什么是执行上下文?
执行上下文(Execution Context) 是 JavaScript 引擎执行一段代码时所创建的运行环境。它包含了代码执行所需的所有信息:
- 变量环境(Variable Environment)
- 词法环境(Lexical Environment)
this绑定- 对外部环境的引用(作用域链)
6.2 执行上下文的生命周期
-
创建阶段(编译阶段) :
- 创建变量环境(var 声明 →
undefined) - 创建词法环境(let/const 声明 → 未初始化,进入 TDZ)
- 确定
this指向 - 建立作用域链
- 创建变量环境(var 声明 →
-
执行阶段:
- 逐行执行代码
- 变量赋值
- 函数调用
-
销毁阶段:
- 执行上下文从调用栈中弹出
- 垃圾回收
6.3 调用栈(Call Stack)
JavaScript 是单线程语言,使用调用栈来管理执行上下文的创建与销毁:
- 全局执行上下文始终位于栈底
- 每次调用一个函数,就创建一个新的执行上下文并压入栈顶
- 函数执行完毕,其执行上下文从栈顶弹出
七、完整的代码执行流程示例
让我们用一个更完整的例子来串联所有知识点:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Hoisting Demo
</head>
<body>
<script>
showName();
console.log(myName);
function showName() {
console.log('函数showName被执行了');
}
</script>
</body>
</html>
这段代码的执行流程如下:
第一步:编译阶段
JavaScript 引擎扫描 <script> 标签内的代码:
- 发现
function showName(){}函数声明 → 将其放入变量环境,完整保留函数体 - 未发现
var声明(该示例中myName未被声明)
编译完成后的变量环境:
javascript
变量环境: {
showName: function() { console.log('函数showName被执行了'); }
}
第二步:执行阶段
- 执行
showName()→ 在变量环境中找到showName→ 正常调用 → 输出'函数showName被执行了' - 执行
console.log(myName)→ 变量环境中没有myName,作用域链中也找不到 → 抛出ReferenceError: myName is not defined
注意: 这与使用 var 声明的情况不同。如果是var myName,编译阶段会将其初始化为 undefined,访问时不会报错。
关键区别 :未声明变量访问报 ReferenceError: xxx is not defined;var 声明未赋值变量访问返回 undefined。
📌 图 5:完整的代码编译与执行全流程
八、常见误区与最佳实践
8.1 常见误区
误区一:变量提升是 "物理移动代码"
三大高频误区汇总
- 误区1:代码物理移动 → 正确:内存预分配,代码位置不变
- 误区2:let/const 无提升 → 正确:有提升,受 TDZ 限制
- 误区3:函数表达式可前置调用 → 正确:仅函数声明可前置调用
- ❌ 错误理解:JS 引擎真的把变量声明移动到了代码顶部
- ✅ 正确理解:编译阶段在内存中提前创建了变量,代码位置不变
误区二:let 和 const 没有提升
- ❌ 错误理解:
let/const声明的变量完全不提升 - ✅ 正确理解:
let/const有提升,但进入暂时性死区(TDZ),声明前访问会报错
误区三:函数表达式和函数声明的提升一样
- ❌ 错误理解:
var foo = function(){}和function foo(){}提升效果相同 - ✅ 正确理解:函数声明整体提升;函数表达式只提升变量名,函数体不提升
8.2 最佳实践
- 优先使用
const,其次let,避免使用varconst和let有 TDZ 保护,能在声明前使用时主动报错,更早暴露问题 - 变量声明放在作用域顶部即使有提升机制,也应该在代码顶部声明变量,保持代码可读性
- 函数声明放在调用之前虽然函数声明可以整体提升,但良好的代码组织胜过依赖语言特性
- 理解原理,但不依赖提升写出清晰、可预测的代码,而不是利用语言特性 "炫技"
核心最佳实践宗旨:利用 TDZ 规避隐性 bug,规范化代码书写,不依赖变量提升的语法特性写代码。
九、总结
核心要点回顾
- JavaScript 代码执行分为两个阶段:编译阶段和执行阶段。编译阶段在代码执行前的瞬间完成。
- 变量提升发生在编译阶段 :JS 引擎会扫描代码,将
var和function声明提升到作用域顶部,并分配内存。 - var 声明的变量 :提升变量名,初始化为
undefined。 - 函数声明:整体提升(包括函数体),可以直接调用。
- 函数表达式 :只有变量名提升(值为
undefined),函数体不提升。 - let 和 const:也有提升,但存储在词法环境中,声明前处于暂时性死区(TDZ),访问会报错。
- 变量提升的本质:是在编译阶段完成内存分配的过程,而不是物理移动代码。
- 变量环境 vs 词法环境 :
var/function存在变量环境中(可在声明前访问);let/const存在词法环境中(不可在声明前访问)。
一张图总结
javascript
JavaScript 代码执行流程:
源代码
│
▼
┌──────────────────────────────┐
│ 编译阶段 (Compilation) │
│ │
│ 1. 词法分析 (Tokenization) │
│ 2. 语法分析 (Parsing) │
│ 3. 创建执行上下文 │
│ ├─ 变量环境 (var/function) │ ← 变量提升发生在这里
│ └─ 词法环境 (let/const) │ ← let/const 进入 TDZ
│ │
└──────────┬───────────────────┘
│
▼
┌──────────────────────────────┐
│ 执行阶段 (Execution) │
│ │
│ 1. 逐行执行代码 │
│ 2. 变量赋值 │
│ 3. 函数调用 │
│ 4. 调用栈管理 │
│ │
└──────────────────────────────┘
理解 JavaScript 的执行原理,尤其是变量提升机制,不仅能帮助我们写出更可靠的代码,还能让我们在遇到诡异 Bug 时,快速定位问题根源。希望本文能帮助你彻底理解这一核心概念!




