JavaScript 变量提升(Hoisting)深度解析
最近深入学习了JS的变量提升底层逻辑,写下此篇和大家分享。
一、从一个反直觉的例子开始
showName(); // 函数showName被执行了
console.log(myName); // undefined
var myName = '极客时间';
console.log(myName); // 极客时间
console.log(add); // undefined
function showName() {
console.log('函数showName被执行了');
}
var add = function(x, y) {
return x + y;
};
按"代码一行一行执行"的直觉,第一行 showName() 应该报错,myName 也应该报错——但事实是:
- 函数在声明之前调用,正常执行。
- 变量在声明之前使用,不报错,但值是 undefined。
这就是变量提升(Hoisting)。
二、什么是变量提升
变量提升是指:在 JS 代码执行过程中,JS 引擎把变量的声明部分和函数的声明部分提升到作用域开头的行为。变量提升后,会给变量设置默认值 undefined。
总结三条规则:
| 情况 | 结果 |
|---|---|
| 使用未声明的变量 | ReferenceError: xxx is not defined |
在 var 声明之前使用变量 | 不报错,值为 undefined |
| 在函数声明之前调用函数 | 不报错,正常执行 |
后两条说明:JS 代码不是一行一行执行的,在执行之前还有一个编译阶段。
三、变量提升的细分情况
3.1 普通变量(var)
// 编译阶段:var myName = undefined; (只有声明被提升)
// 执行阶段:才执行赋值
console.log(myName); // undefined
var myName = '极客时间';
console.log(myName); // 极客时间
3.2 函数声明
// 编译阶段:整个函数声明被提升(函数是一等公民)
showName(); // 正常执行
function showName() {
console.log('执行了');
}
3.3 函数表达式
console.log(add); // undefined
var add = function(x, y) {
return x + y;
};
函数表达式本质上是一个变量赋值——编译阶段只有 var add = undefined;,赋值操作在执行阶段才发生。所以在赋值之前调用 add() 会报错。
四、变量提升的"物理移动"是误解
很多人认为"变量提升就是把声明移动到作用域最前面",但这是不准确的。
事实是:变量和函数声明的代码位置不会改变,而是在编译阶段被 JS 引擎放入内存中。
// 源代码不会变成这样:
// var myName = undefined;
// function showName() { ... }
// showName();
// myName = '极客时间';
// 而是在编译阶段,JS 引擎将声明存入执行上下文,执行阶段再按顺序执行。
五、JS 代码的执行流程
源代码 → [编译阶段] → [执行阶段]
↓
生成两部分内容:
1. 执行上下文(Execution Context)
2. 可执行代码(字节码)
执行上下文
执行上下文是 JS 执行一段代码的运行环境,由两部分组成:
- 变量环境(Variable Environment):存放
var声明的变量和函数声明 - 词法环境(Lexical Environment):存放
let和const声明的变量
每次调用一个函数,都会创建一个新的执行上下文,压入调用栈。
六、let / const 为什么"没有"提升
console.log(myName); // 报错!
let myName = 'adofa';
// ReferenceError: Cannot access 'myName' before initialization
let 和 const 实际上也提升了(在编译阶段分配了内存),但它们的存储位置是词法环境,而不是变量环境。
在声明之前,词法环境中的变量处于暂时性死区(TDZ, Temporal Dead Zone),访问会直接报错。
| 对比维度 | var | let / const |
|---|---|---|
| 是否提升 | 是 | 是 |
| 存储位置 | 变量环境 | 词法环境 |
| 声明前访问 | undefined | 报错(TDZ) |
| 可重复声明 | 允许 | 不允许 |
七、用"模拟提升"来理解
理解了编译/执行两阶段后,可以手动模拟编译阶段的效果。比如原始代码:
showName();
console.log(myName);
var myName = 'aaaf';
function showName() {
console.log('执行showName');
}
编译阶段"等价于":
// === 编译阶段完成的动作 ===
var myName = undefined;
function showName() {
console.log('执行showName');
}
// === 执行阶段按顺序执行 ===
showName(); // 执行showName
console.log(myName); // undefined
myName = 'aaaf';
八、完整的内存模型
以这段代码为例:
showName();
console.log(myName);
var myName = '...';
function showName() {
var a = 1;
console.log('showName');
}
编译阶段 1(全局):
GlobalExecutionContext = {
变量环境: {
myName: undefined,
showName: function() { ... }
},
词法环境: {}
}
执行阶段:调用 showName() → 编译阶段 2:
ShowNameExecutionContext = {
变量环境: {
a: undefined
},
词法环境: {}
}
每次进入一个新的函数调用,都会创建新的执行上下文。这正是"调用栈"的基础。
九、一句话总结
变量提升的本质,是 JS 引擎在编译阶段完成变量和函数声明的内存分配。
var和函数声明存入变量环境(可在声明前访问),let/const存入词法环境(声明前为 TDZ,访问即报错)。代码位置不变,变的只是内存中"有没有"这个东西。
基于 V8 引擎原理整理,适合配合动手调试加深理解。