面试必问、工作必踩的 JS 经典话题,一篇带你从现象深入到 V8 引擎的执行原理。
目录
- 先看现象:代码为什么没有报错?
- [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")
- [var 变量提升](#var 变量提升 "#3-var-%E5%8F%98%E9%87%8F%E6%8F%90%E5%8D%87")
- [函数提升:声明 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")
- 函数优先:一等公民的特权
- 同名函数:后者覆盖前者
- [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")
- [变量环境 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")
- 模拟变量提升:引擎到底做了什么?
- 总结:一图胜千言
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输出undefinedadd也输出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 词法环境
这是理解 var 和 let 差异的关键:
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 是什么意思? - 变量环境和词法环境分别存放什么?
- "变量提升是内存提前分配" 怎么理解?
参考答案
- 完全没声明的变量和 var 声明的变量,在声明前访问有什么区别?
- 完全没声明 :抛出
ReferenceError: x is not defined。JS 引擎在编译阶段没有为它分配任何内存,执行阶段找不到这个标识符,直接报错。 - var 声明了 :输出
undefined,不报错。编译阶段已经为它分配内存并初始化为undefined,执行阶段访问时变量已经存在,只是还没被赋值。
- 函数声明和函数表达式的提升有什么区别?
- 函数声明 (
function foo() {}):整个函数体都被提升,在声明之前调用可以正常执行。 - 函数表达式 (
var foo = function() {}):只有变量名foo被提升(值为undefined),函数体本身不会提升。在赋值之前调用会抛TypeError: foo is not a function。
- showName() 同时有函数声明和 var 声明,调用的是哪个?
调用的函数声明 的那个。编译阶段函数声明优先于 var 声明 ,同名的 var 声明会被忽略(不会把函数覆盖为 undefined)。所以调用时 showName 指向的是函数对象,输出 1。
4. let 到底有没有提升?TDZ 是什么意思?
- let 有提升 :编译阶段 JS 引擎在词法环境中为 let 变量分配了内存空间,但不会初始化为 undefined。
- TDZ(暂时性死区) :从代码块开始到 let 声明语句执行之前的这段区域。变量已经分配了空间但处于"未初始化"状态,在 TDZ 内访问变量会抛出
ReferenceError。直到执行到声明语句,变量才完成初始化,TDZ 结束。
- 变量环境和词法环境分别存放什么?
- 变量环境(Variable Environment) :存放
var声明的变量和函数声明,编译阶段初始化为undefined,可以在声明前访问。 - 词法环境(Lexical Environment) :存放
let/const声明的变量,编译阶段分配空间但不初始化,在声明前处于 TDZ,不可访问。
- "变量提升是内存提前分配" 怎么理解?
变量提升并不是代码被物理移动到了顶部,而是在编译阶段 ,JS 引擎扫描代码中的声明语句,提前为变量和函数分配内存空间。到了执行阶段,代码按顺序执行时,这些变量已经存在于内存中了,所以可以访问------只是 var 的值为 undefined、let 还未初始化。代码的位置没变,变的是内存已经在编译阶段准备好了。