别再背"var 提升,let/const 不提升"了:揭开暂时性死区的真实面目
你可能听过:"
var有变量提升,let和const没有。"但当你写
console.log(x); let x = 1;报错时,真的就是"没提升"吗?这篇文章会帮你彻底搞懂提升、暂时性死区(TDZ)以及它们背后的设计原因。
1. 一个常见的"误解"
很多 JS 入门教程会告诉你:
var有变量提升,可以在声明前访问(值为undefined)。let和const没有变量提升,声明前访问会报错。
于是你记住了结论,但一遇到下面的代码又开始困惑:
js
let x = 1;
function test() {
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 2;
}
如果 let 真的"不提升",为什么输出不是外层的 1 呢?
这恰恰说明:let 和 const 其实也提升了,只是行为不同。
2. 什么是"提升"?
JavaScript 引擎在执行代码前,会先进行编译阶段 。在这个阶段,它会将所有变量和函数的声明移动到当前作用域的顶部 。这个过程就叫提升(Hoisting)。
注意:提升的是声明,而不是赋值。
2.1 函数声明的提升
函数声明会被整体提升,所以你可以在声明之前调用函数:
js
sayHello(); // 输出 "Hello"
function sayHello() {
console.log("Hello");
}
因为引擎实际看到的代码是顺序是:
js
function sayHello() { console.log("Hello"); }
sayHello();
2.2 var 的变量提升
var 声明的变量也会被提升,但只提升声明,不提升赋值 ,初始值为 undefined。
js
console.log(a); // undefined(不是报错)
var a = 10;
实际效果:
js
var a; // 提升到顶部,初始值 undefined
console.log(a);
a = 10;
3. let 和 const 真的"不提升"吗?
先看这段代码:
js
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 20;
如果 let 完全不提升,那么 b 在声明前应该根本不存在,错误应该是 b is not defined(未声明的变量错误)。
但实际错误是 "Cannot access before initialization" (初始化前无法访问)。这暗示了:引擎已经知道 b 存在于当前作用域,只是不允许你在它初始化之前使用。
同样的现象也出现在 const 上。
3.1 暂时性死区(TDZ)
实际上,let 和 const 也会提升 。但它们有一个额外的限制:从进入作用域到声明语句之间,变量处于"暂时性死区"(Temporal Dead Zone, TDZ) 。在这期间访问变量会抛出 ReferenceError。
所以,更准确的描述是:
var:提升 + 初始化为undefinedlet/const:提升 + 不初始化,且在声明前禁止访问
4. 为什么要有"暂时性死区"?直接不提升不行吗?
你可能会想:既然声明前不让用,那不如干脆不提升,让变量在声明前不存在,不是更简单?
4.1 首先,JavaScript 做不到"不提升"
JavaScript 采用词法作用域 (也叫静态作用域),变量的作用域在编译时 就已经确定了。为了知道一个标识符到底属于哪个作用域(是全局、函数内还是块内),引擎必须在编译阶段就把所有变量声明注册到对应的作用域。这个注册过程就是"提升"。
例如:
js
let x = 1;
{
let x = 2;
}
如果没有编译阶段的注册,内部的 x 就无法与外部 x 区分开,作用域规则就乱套了。因此,无论 var、let 还是 const,都必须提升(即注册到作用域)。
4.2 如果"不提升",会出现什么灾难?
假设 JavaScript 真的让 let 完全不提升,即在声明前它不注册到当前作用域。那么看这段代码:
js
let x = 1;
function test() {
console.log(x); // 按"不提升"的假设,这里应该去外层找 x
let x = 2;
}
test();
如果引擎在编译时没有把内部的 x 注册到 test 函数作用域,执行到 console.log(x) 时,它会沿着作用域链向外查找,找到全局的 x = 1。然后输出 1,再执行 let x = 2 声明一个局部变量。
这会导致极其隐蔽的 bug :开发者以为内部声明了一个局部变量,但实际上却意外地访问到了外层的变量。这与 let 的设计宗旨------变量必须声明后才能使用,且不与上层作用域混淆------完全相悖。
4.3 TDZ 正是为了解决这个问题
let / const 的设计方案是:
- 编译阶段:将变量提升到当前作用域顶部(注册),但标记为"未初始化"。
- 执行阶段:从作用域顶部到声明语句之间,形成 TDZ,任何访问都报错。
- 执行到声明语句 :
- 如果有初始化(
let x = 10),则此时变量被初始化并赋值。 - 如果只有声明(
let x;),则初始化为undefined。
- 如果有初始化(
这样既保证了变量在声明前不会意外访问到外层同名变量(因为引擎知道当前作用域有这个变量,不会向外找),又强制你必须先声明后使用,代码更安全、更可预测。
5. 一个直观对比
| 声明方式 | 是否提升 | 初始值 | 声明前访问 | 表现 |
|---|---|---|---|---|
| 函数声明 | ✅ 整体提升 | 函数体 | ✅ 可以 | 正常调用 |
var |
✅ 提升 | undefined |
✅ 可以(值为 undefined) |
不报错,但可能拿到意外值 |
let |
✅ 提升(但 TDZ) | 无 | ❌ 报错 | ReferenceError: Cannot access before initialization |
const |
✅ 提升(但 TDZ) | 无 | ❌ 报错 | 同上,且必须声明时初始化 |
6. 最佳实践建议
- 默认使用
const,只有当变量需要被重新赋值时才用let。 - 禁止使用
var,除非你明确需要利用它的提升特性(极少场景)。 - 在作用域顶部声明变量,避免 TDZ 带来的困扰(虽然 TDZ 是规范,但写成先声明后使用是最清晰的)。
7. 总结
- 所有声明(
var、let、const、函数声明)都会提升,本质是编译阶段将变量/函数注册到作用域。 var在提升时初始化为undefined,允许提前访问(但容易导致 bug)。let/const也提升,但进入 TDZ,在声明前访问会报错,强制你先声明后使用。- TDZ 的存在是为了在不破坏词法作用域的前提下,避免"变量泄漏"到外层作用域,同时提供更严格的编程约束。
- 下次面试官问你"
let和const有变量提升吗?",你可以自信地回答:"有的,但存在暂时性死区。"
💬 互动:你在实际开发中遇到过因 TDZ 导致的 bug 吗?评论区分享你的经历,我们一起避坑。
(完)