写了多年 JS 却仍被"变量提升"拿捏?这篇文章一次讲透
很多人写 JS 三五年,面试一问"变量提升是什么",张口就是:
"就是变量声明提升到作用域顶部嘛。"
听起来没错,但再往下问------
var、let、const 的提升有啥区别?
函数表达式和函数声明谁提升得更高?
暂时性死区到底是啥?
为什么 var a = 10 提升后 a 是 undefined 而不是 10?
往往就开始含糊其辞了。
这篇文章不背八股,我们从 JS 真正执行前的那一步------编译阶段 说起,把变量提升这件事彻底拆清楚。
一、变量提升的本质:不是"搬家",而是"登记"
很多人以为"提升"是浏览器把代码物理地挪到顶部,其实完全不是。
真相是:JS 在执行代码前,会先进行一次"编译扫描",在这个阶段完成变量和函数的"绑定/注册"。
这个过程叫做 声明阶段(Declaration Phase) ,真正执行才是 赋值阶段(Assignment Phase) 。
ini
console.log(a);
var a = 10;
发生了什么?
- 编译阶段 :JS 引擎扫描代码,发现
var a,于是在当前作用域的变量环境中登记一个变量a,初始值为undefined。 - 执行阶段 :走到
console.log(a),读取a,此时它还是undefined。 - 再往后执行到
a = 10,才给a赋值为10。
所以你看到的"提升",本质上是:
声明在编译期完成,赋值在执行期完成。
二、var:最容易被误解的提升
1. var 的"真面目"
ini
console.log(a); // undefined
var a = 10;
等价于:
less
var a; // 编译阶段:声明 + 初始化为 undefined
console.log(a); // undefined
a = 10; // 执行阶段:赋值
关键点有三个:
- ✅ 声明提升
- ✅ 自动初始化为
undefined - ❌ 赋值不提升
这也是为什么你能访问到一个"已声明但未赋值"的变量,而不会直接报错。
2. var 的函数级作用域
scss
function test() {
console.log(a);
if (false) {
var a = 10;
}
}
test(); // undefined
哪怕 if (false) 永远不会执行,var a 依然会被提升,并且属于整个函数作用域。
三、函数提升:比变量提升"更强势"
1. 函数声明:整体提升
scss
foo();
function foo() {
console.log('hello');
}
✅ 正常运行。
因为函数声明在编译阶段就已经完成了 声明 + 赋值(指向函数体)。
等价于:
scss
function foo() {
console.log('hello');
}
foo();
2. 函数表达式:按 var 规则来
javascript
foo(); // TypeError: foo is not a function
var foo = function () {
console.log('hello');
};
这里 foo 的提升规则和 var 一模一样:
javascript
var foo; // 编译阶段:foo = undefined
foo(); // 执行阶段:undefined(),直接崩
foo = function () { ... }; // 后面才赋值
⚠️ 注意错误类型:
ReferenceError:变量根本没声明TypeError:变量存在,但类型不对(比如拿undefined当函数调用)
3. 优先级:函数声明 > 变量声明
ini
console.log(foo);
var foo = 'bar';
function foo() {}
输出的是:
javascript
ƒ foo() {}
原因:
编译阶段,函数声明和变量声明都会被提升,但 函数声明优先级更高,会覆盖同名变量的"占位"。
不过如果变量有赋值,执行阶段还是会覆盖回去:
javascript
console.log(foo); // ƒ foo() {}
var foo = 'bar';
function foo() {}
console.log(foo); // 'bar'
四、let / const:真的"没有提升"吗?
这是最多人误解的地方。
1. TDZ(暂时性死区)现身
ini
console.log(a); // ReferenceError
let a = 10;
于是很多人得出结论:let 不存在提升。
❌ 这个结论并不准确。
事实上:
let / const同样存在提升,只是没有被自动初始化。
ES6 规范中引入了 Temporal Dead Zone(TDZ,暂时性死区) :
- 从作用域开始,到
let / const声明语句之前 - 这段时间内,变量虽然已经存在于作用域中
- 但无法访问,也无法读写
javascript
{
// TDZ 开始
console.log(a); // ❌
let a = 10; // TDZ 结束
}
2. let vs var 的核心差异
| 特性 | var | let / const |
|---|---|---|
| 是否提升 | ✅ | ✅ |
| 自动初始化 | ✅ undefined | ❌ |
| 作用域 | 函数级 | 块级 |
| TDZ | ❌ | ✅ |
一句话总结:
var提升并初始化;let / const提升但不初始化。
3. const 的特殊点
arduino
const a; // SyntaxError
const 不仅没有自动初始化,而且 必须在声明时赋值,否则直接语法报错。
五、经典面试题逐题拆解
题目 1:连环坑
ini
var a = 1;
function foo() {
console.log(a);
var a = 2;
}
foo();
答案:undefined
解析:
foo内部有var a- 函数作用域内
a被提升,初始化为undefined - 遮蔽了外层的全局
a - 所以打印的是
undefined
题目 2:混合提升
ini
console.log(typeof a);
var a = 1;
let b = 2;
答案:
javascript
'undefined' // typeof a
ReferenceError // console.log(b)
解析:
var a提升并初始化为undefined,typeof不会报错let b处于 TDZ,typeof b也会直接抛错(这点在老面试官眼里是加分项)
题目 3:循环中的 var vs let
javascript
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3 3 3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0 1 2
原因:
var i:整个循环共享同一个ilet i:每次迭代都创建一个新的块级作用域i- 这正是 块级作用域 + 提升规则 的典型实战体现
六、为什么会有变量提升?(设计层面的原因)
理解了"是什么",我们再往前走一步,看看"为什么"。
1. 函数互相调用的需要
csharp
function a() {
b();
}
function b() {
a();
}
如果没有提升,无论哪个函数写在前面,另一个都会找不到。
提升机制让函数在编译阶段就全部就绪,支持互相调用。
2. 历史包袱
JS 最初被设计为"在浏览器里跑几行的小脚本语言",
设计者为了降低上手门槛,选择了:
- 变量自动初始化
- 容错而非严格报错
这些决策在当时是合理的,但也留下了今天的"坑"。
七、现代 JS 的最佳实践
既然搞懂了提升,那在实际项目中该怎么写?
✅ 1. 默认使用 let / const
- 拒绝
var - 明确块级作用域
- 避免 TDZ 之外的隐式 bug
✅ 2. 先声明,后使用
即使 let 允许你"技术上"在下面声明,也不要这么做:
ini
// bad
console.log(a);
let a = 10;
// good
let a = 10;
console.log(a);
✅ 3. 函数优先使用表达式
javascript
// good
const greet = () => {
console.log('hi');
};
相比函数声明:
- 提升行为更可控
- 符合
const的不可重复赋值语义 - 更容易配合模块化和 tree-shaking
八、一句话总结
变量提升不是"代码搬家",而是"编译期登记"。
var:声明 + 自动初始化 → 可提前访问,值是undefinedlet / const:声明提升,但无自动初始化 → TDZ 内访问直接报错- 函数声明:整体提升,优先级高于变量
- 函数表达式:按
var规则提升
当你不再把"提升"当成一句口诀,而是能从 编译 → 执行 → 作用域 → TDZ 这一整条链路去解释它时,
你就真正跨过了"写 JS 多年却不懂变量提升"的那道坎。