从变量提升到块级作用域:JavaScript 作用域机制的演进之路
在学习 JavaScript 的过程中,很多开发者都会被“变量提升”这一特性搞得晕头转向。明明代码还没执行到变量声明的地方,却已经可以访问;明明写了一个 let 声明,却报错说“不能在初始化前访问”。这些看似“反直觉”的行为背后,其实隐藏着 JavaScript 引擎对作用域机制的历史妥协与现代优化。
本文将从变量提升的由来出发,结合实际代码,解释为何 ES5 的设计存在缺陷,并进一步说明 ES6 是如何通过块级作用域与词法环境的引入,优雅地解决了这些问题。
一、变量提升:历史的产物
1.1 什么是变量提升?
在 ES5 及更早版本中,使用 var 声明的变量和函数声明会被“提升”到其所在作用域的顶部。例如:
showName(); // 正常输出:“函数showName 执行了”
console.log(myname);
var myname = "瞿翔";
function showName() {
console.log('函数showName 执行了');
}
虽然 showName() 调用出现在函数定义之前,但 JavaScript 引擎在编译阶段就将函数声明提升到了作用域顶部,因此可以正常调用。
而 var 声明的变量也会被提升,但只有声明被提升,赋值操作则留在原地,所以 console.log(myName) 输出 undefined(如果变量名正确的话)。
1.2 为什么会有变量提升?
变量提升是早期为了简化引擎实现而做出的妥协。它让解析器无需关心变量声明的具体位置,只需在进入作用域时一次性处理所有 var 和函数声明。
但这带来了严重的问题:
- 变量可能在未预期的地方被覆盖;
- 无法限制变量的作用范围(比如
if、for内部); - 容易引发难以调试的逻辑错误。
二、块级作用域缺失带来的问题
2.1 var 不支持块级作用域
var name = "刘锦苗";
function showName() {
console.log(name); // 输出刘锦苗?
if (false){
var name = "大厂的苗子";
}
console.log(name);
}
showName();
你可能会以为 if 块内的代码永远不会执行,所以第一个打印的结果应该是“刘锦苗”,但实际上的结果是undefined!!!,这也是为什么我们说js是反直觉的代码
- 真实的原因是什么呢
那就是由于var是js早期的设计,它并不支持块级作用域,在if中的name会变量提升到整个函数作用域中,覆盖掉了全局中的“刘锦苗”,但赋值操作留在原地不会被执行到,所以会输出undefined。
这显然违背了开发者的直觉——我们希望 if 块内的变量只在块内有效。
2.2 循环中的经典陷阱
function foo() {
for (var i = 0; i < 7; i++) { }
console.log(i); // 输出:7
}
foo();
i 在循环结束后仍然可访问!如果后续代码不小心用了 i,就可能引发 bug。这也是为什么在 ES6 之前,很多人用 IIFE 来模拟块级作用域。
三、ES6 的救赎:let / const 与块级作用域
为了解决上述问题,ES6 引入了 let 和 const,并正式支持块级作用域。
3.1 块级作用域是什么?
块级作用域指由 {} 包裹的代码区域(如 if、for、while、甚至独立的 {} 块)。在 ES6 中,let/const 声明的变量仅在当前块内有效。
let name = "刘锦苗";
{
console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = "大厂的苗子";
}
这里报错不是因为变量不存在,而是因为进入了 “暂时性死区”(Temporal Dead Zone, TDZ) —— 在 let name 声明之前,该变量不可访问。
这正是 ES6 的设计哲学:宁可报错,也不让你写出有歧义的代码。
3.2 执行上下文的双环境模型
根据你的笔记,V8 引擎在执行上下文创建时,会维护两个环境:
- 变量环境(Variable Environment) :存放
var声明,支持变量提升。 - 词法环境(Lexical Environment) :存放
let/const声明,支持块级作用域和 TDZ。
考虑下面这段代码:
function foo() {
var a = 1;
let b = 2;
{
let b = 3; // 新的块级作用域,b 被压入词法环境栈
var c = 4; // c 属于函数作用域(变量环境)
let d = 5;
console.log(a); // 1
console.log(b); // 3(查找当前块的词法环境)
}
console.log(b); // 2(块结束,b=3 出栈)
console.log(c); // 4(var 提升到函数顶部)
console.log(d); // ReferenceError: d is not defined
}
foo();
我对代码的理解:
- 作用域链
对于代码中console.log(a),它被定义中块级作用域中,它是如何寻找a的呢,首先他会查找自身(块级作用域)是否声明了该元素,如果有那就停止查找,没有则会向他外层的作用域进行查找,这就是作用域的嵌套,直到找到该元素或者搜索到全局作用域也没找到。
下面用一张图加深你对它的理解:
- 块级作用域
块级作用域虽然不能创建一个新的词法环境,但却能够在当前词法环境中维护一个栈区,这里我们解释代码中console.log(b)和console.log(d)为什么是上述的输出结果。
下面用一张图解释:他们在函数执行上下文中编译阶段的关系
当我们在块级作用域中定义了一个新的b时,它并不会引起报错,因为它其实并不会影响到函数全局的b变量,他们实际上的关系就和图中一样,当我们在块级作用域中使用let/const声明一个变量时,块级作用域实际上会在当前函数的词法环境中维护一个栈结构,一个块级作用域就是一个栈区,他们并不会互相干扰,当这个块级作用域执行完毕后,就会出栈,这就符合了js的垃圾回收机制,不会污染整个的函数的环境,所以当我们在块级作用域之外的地方想要去访问块级作用域中由let/const声明的变量,这是不符合规则的,他只会沿着作用域链去查询该变量,这也是为什么代码中访问d就会报错的原因--因为函数作用域中根本没有d这个变量!!
四、为什么 ES6 要“一国两制”?
你可能会问:既然 let/const 更好,为什么不直接废弃 var?
答案是:向下兼容。
JavaScript 已经广泛应用于全球数十亿网页,任何破坏性变更都可能导致旧网站崩溃。因此,ES6 采取了“新旧并存”的策略:
- 保留
var和变量提升,维持旧代码行为; - 新增
let/const,提供更安全、更符合直觉的作用域规则。
这也解释了为何现代最佳实践强烈推荐:永远使用 let/const,避免 var。
五、总结
| 特性 | var(ES5) | let/const(ES6) |
|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 |
| 变量提升 | 提升 | 存在暂时性死区 |
| 重复声明 | 允许 | 不允许 |
| 全局对象属性 | 是 | 否 |
变量提升是历史的妥协,块级作用域是未来的方向。理解这两者的差异,不仅能帮你写出更健壮的代码,也能深入掌握 JavaScript 引擎的工作原理。
“站在执行上下文的角度,
var放在变量环境,let/const放在词法环境。”
这不仅是语法的改变,更是 JavaScript 语言成熟化的标志。
建议:在日常开发中,彻底告别 var,拥抱 const 优先、let 备用的原则,让代码更清晰、更安全!