摘要: 你是否曾被 var 的 undefined 和 let 的暂时性死区搞得头大?为什么 for 循环里的 i 有时能访问,有时却报错?这一切的背后,是 JavaScript 引擎(V8)为了兼容历史与追求完美而上演的“一国两制”大戏。本文将带你透过核心理论,结合真实代码案例,深入执行上下文的内部,看透变量是如何被查找、存储和销毁的。
引言:一场由“设计缺陷”引发的思考
JavaScript 诞生之初,仅仅是为了给网页加点动态效果的“KPI 项目”。为了在激烈的浏览器商业竞争中快速上线,Brendan Eich 仅用了 10 天就设计出了这门语言。
当时的简单设计(如变量提升、缺乏块级作用域)在后来复杂的工程中变成了“坑”。为了解决这些问题,ES6 引入了 let/const 和块级作用域。
但你知道吗?为了向下兼容,JavaScript 引擎并没有丢弃旧的机制,而是采用了一种 “一国两制” 的策略:var 和 let 在引擎内部的待遇截然不同。
一:编译与执行的“双面人生”
在深入了解作用域之前,我们需要先了解 JS 引擎(以 V8 为例)的工作流程。
JS 的执行分为两个阶段:编译(创建执行上下文) 和执行。
当一个函数被调用时,引擎会创建一个执行上下文(Execution Context) 。你可以把它想象成一个官员的“办公桌”,桌上分成了两个区域:
- 变量环境(Variable Environment) :专门伺候
var。 - 词法环境(Lexical Environment) :专门伺候
let和const。
这就是解决“一国两制”问题的关键——把新旧规则分开管理。
二:旧时代的“贵族”—— var 与 变量提升
var 是旧时代的“贵族”。在编译阶段,引擎会扫描整个作用域,把所有用 var 声明的变量统统提到作用域顶部,并赋予初始值 undefined。这就是变量提升(Hoisting) 。
我给一个例子:
showName();
console.log(myname); // undefined
var myname = "张三";
function showName(){
console.log('函数showName执行了');
}
底层逻辑:
在编译阶段,引擎眼中的代码其实是这样的:
var myname = undefined; // 变量提升
function showName(){ ... } // 函数声明也会提升(优先级更高)
// 执行阶段
showName(); // 能执行
console.log(myname); // undefined (变量已声明但未赋值)
myname = "张三"; // 此时才赋值
为什么这是缺陷?
正如文档中所说,这种设计导致了变量容易被意外覆盖,且本该销毁的变量因为提升而一直存活,造成了内存浪费和逻辑混乱。
三:新时代的“公民”—— let/const 与 暂时性死区
ES6 为了纠正这个历史错误,引入了 let 和 const。它们不再享受“贵族特权”,而是被放进了执行上下文的另一个区域——词法环境。
核心机制:
- 不提升(其实是提升但不可用):
let变量虽然也在编译阶段被注册,但它不会被初始化为undefined。 - 暂时性死区(TDZ): 从代码块开始到
let声明语句执行之前,这个变量一直处于“死区”。如果此时访问,引擎会直接报错ReferenceError。
举个例子:
let name = '张三'
{
console.log(name); // ❌ 报错:Cannot access 'name' before initialization
let name = '李四'
}
底层逻辑:
虽然外面有个 name,但因为块级作用域内声明了 let name,它遮蔽了外部的 name。
在块级作用域内,引擎在编译阶段看到了 let name,但在执行到赋值语句前,它处于“死区”。所以 console.log(name) 拿到的不是外部的 张三,也不是 undefined,而是报错。
四:词法环境的“栈”结构与 块级作用域
这是最精彩的部分。词法环境内部维护了一个小型栈结构。
let 的出现让 JavaScript 终于支持了块级作用域(由 {} 包裹的区域)。在引擎眼中,词法环境像一个栈(Stack) 。
依旧举个例子:
function foo() {
var a = 1;
let b = 2; {
let b = 3;
var c = 4;
let d = 5;
console.log(a) // 1
console.log(b) // 3
}
console.log(b) // 2
console.log(c) // 4
console.log(d) // ❌ 报错
}
底层逻辑(栈视角):
-
进入函数: 词法环境栈压入第一层:
b: 2。 -
进入内部代码块
{}: 栈顶压入第二层:b: 3,d: 5。- 此时查找变量,先看栈顶。所以
b是3。 var c无视块级作用域,直接提升到了函数作用域(第一层)。
- 此时查找变量,先看栈顶。所以
-
离开代码块: 第二层出栈销毁。
b恢复为第一层的2。d随着第二层出栈而彻底消失,外界无法访问。
这就是为什么 c 能打印 4,而 d 会报错的原因。
第五章:var 与 let 的对比
让我们再看几个经典的对比案例,来验证我们的理论。
场景一:if 块里的陷阱
var 版本:
var name = "张三";
function showName() {
console.log(name); // ❌ undefined
if (true) {
var name = "李四" // 提升到了 showName 函数顶部
}
}
- 解析:
var没有块级作用域。if里的var name提升到了showName函数的顶部(变量环境),导致函数内的name覆盖了全局的name。在赋值前打印,就是undefined。
let 版本:
let name = "张三 ";
function showName() {
console.log(name); // ✅ "张三"
if (false) {
let name = "李四" // 从未执行,但不影响外部
}
}
- 解析:
let不提升。函数内部没有声明name,所以顺着作用域链找到了全局的张三。
场景二:循环中的秘密
function foo() {
for (let i = 0; i < 7; i++ ) {
// i 是块级作用域的
}
console.log(i); // ❌ 报错
}
- 解析:
let在for循环中声明的变量,只存在于循环体这个“栈帧”中。一旦循环结束,栈帧弹出,i就被回收了。而var i会泄露到函数作用域中。
结语:拥抱词法环境
回顾 JavaScript 的作用域发展史,从 var 的“变量提升”到 let 的“暂时性死区”,从缺乏块级作用域到词法环境的“栈结构”管理,这不仅是语法的更新,更是语言设计的进化。
V8 引擎通过将执行上下文拆分为变量环境(兼容旧时代)和词法环境(拥抱新时代),完美地解决了“一国两制”的难题。
作为开发者,理解这套底层机制,能让我们写出更安全、更符合直觉的代码。下次再遇到 ReferenceError 或 undefined 时,你就知道,那是引擎在告诉你: “变量还在死区里,请先声明再使用!”