从“变量提升”到“块级作用域”:一文彻底搞懂 JavaScript 作用域与执行上下文
关于JavaScript 的作用域与变量提升这个话题:
- 为什么
var会让人抓狂? let/const是怎么“拯救世界”的?- 传说中的“暂时性死区”到底是个啥?
- 块级作用域背后真正的实现原理是什么?
- 以及……为什么说 JavaScript 是一国两制?
准备好了吗?系好安全带,我们出发!
一、开胃菜:几段让人怀疑人生的代码
showName(); // 函数showName执行了 ✅
console.log(myname); // undefined ✅
var myname = '路明非';
function showName(){
console.log('函数showName执行了')
}
函数声明提升了,var 声明的变量也提升了,但只是提升声明,赋值留在原地。这就是经典的变量提升(hoisting)。
// 暂时性死区的经典受害者
let name = '柯基';
{
console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = '大狗嚼';
}
同名变量,let 居然直接报错?这就是我们要讲的暂时性死区(TDZ)。
// var 经典翻车现场
var name = '柯基';
function showName(){
console.log(name); // undefined!不是 '柯基'!
if(true){
var name = '大狗嚼';
}
console.log(name); // 大狗嚼
}
showName();
明明全局有 name = '柯基',为什么函数里第一次打印是 undefined?因为 var name 被提升到了函数最顶部,遮蔽了外面的变量!
这些“反直觉”的行为,根源都在 JavaScript 的执行机制上。
二、JavaScript 到底是怎么运行的?—— 两个阶段 + 执行上下文
V8 引擎(以及所有现代 JS 引擎)运行代码分为两个阶段:
- 编译阶段:创建执行上下文(Execution Context)
- 执行阶段:真正运行代码
为了让大家更直观地感受两者的区别,先上第一张经典对比图:
注意!这里有两个环境!
变量环境:只存放var和函数声明(经典的变量提升)词法环境:存放let/const/class,并且支持块级作用域
这就是传说中的 “一国两制”!
三、为什么早期 JavaScript 要有变量提升?—— 一个历史遗留的 KPI 项目
1995 年,Netscape 工程师布兰登·艾克(Brendan Eich)只用了 10 天 就设计出了 JavaScript,目标只是“让网页动起来”。
当时谁也没想到它会统治前端 20 年。
为了最快实现动态效果,设计上做了很多妥协:
- 不支持块级作用域(省事)
- 所有变量都提升到函数顶部(实现最简单)
于是就有了:
if (false) {
var x = 123;
}
console.log(x); // undefined,而不是报错!
var 完全无视 {},直接提升到函数顶部。这就是 ES5 时代无数 bug 的源头。
四、变量提升的两大罪状
- 变量容易被意外覆盖(变量遮蔽失效)
- 本该销毁的变量没有销毁(内存泄漏隐患)
// 罪状示范
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 1000);
}
// 1秒后打印 5 5 5 5 5 —— 经典闭包陷阱
五、ES6 如何完美解决?—— let/const + 块级作用域 + 暂时性死区
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 1000);
}
// 1秒后打印 0 1 2 3 4 ✅
这是怎么做到的?
答案就在 词法环境(LexicalEnvironment)的栈式结构!
词法环境的“栈中栈”结构(超级重要!)
每次进入一个块级作用域({}),JS 引擎就会在词法环境中压入一个新的环境记录:
function foo(){
var a = 1;
let b = 2;
{
// → 新建块级词法环境,压栈
let b = 3; // 只存在于这个块
var c = 4; // var 依然提升到函数的 VariableEnvironment
let d = 5;
console.log(a); // 1
console.log(b); // 3 ← 找到栈顶的 b
console.log(d); // 5
} // ← 块结束,环境出栈,b=3 和 d=5 被销毁
console.log(b); // 2 ← 回到上一层,b又变成2
console.log(c); // 4 ← var 依然存在
// console.log(d); // ReferenceError!
}
foo();
这背后到底发生了什么?我们用 5 张图一步步拆解(建议放大看细节)!
foo 函数刚创建时的执行上下文(编译阶段)
foo 函数刚创建时的执行上下文:
- var a、var c 已经被提升到 变量环境中,初始值为 undefined
- 所有 let/const 变量(包括块里的)都已经被“登记”到词法环境中,但处于 未初始化 状态 → 这就是暂时性死区(TDZ)
执行到块作用域前一刻
执行到块前:
外层 let b = 2 已经完成赋值,退出 TDZ。
进入块级作用域的瞬间(最精彩的一幕!)
进入块级作用域:
关键来了! 每进入一个 {} 块,JS 引擎就在词法环境中压入一个新的环境记录(栈结构):
- 块内的 let b = 3、let d = 5 放在栈顶
- var c = 4 依然提升到最外层的 变量环境
这才是 ES6 真正实现块级作用域的底层原理!
图5:块内打印时变量查找路径
块内查找变量:
查找顺序:
- 当前块的词法环境 → 找到 b = 3
- 外层词法环境 → b = 2
- 变量环境 → a = 1、c = 4
图6:块结束,环境出栈!
块结束出栈:
- 栈顶环境被销毁,b = 3、d = 5 彻底消失
- b 重新回到外层的 2
- var c = 4 依然存在(因为 var 不支持块级)
这套“词法环境的栈中栈”机制,就是 let 完美解决 for 循环闭包问题的根本原因!
六、暂时性死区(TDZ)到底是什么?
console.log(typeof x); // undefined (x未声明)
console.log(typeof name); // ReferenceError!(name被let声明了,但还在TDZ)
let name = '柯基';
规则很简单:
一旦某个块作用域内出现了
let/const声明这个变量名,从块开始到声明语句之前,这段区域就是“死区”,访问会直接抛错。
这其实是故意设计出来的“保护机制”,防止你使用尚未初始化的变量。
七、经典对比表(建议收藏)
| 特性 | var | let/const |
|---|---|---|
| 是否提升 | 是(声明+初始化为undefined) | 是(只提升声明,不初始化) |
| 暂时性死区 | 无 | 有 |
| 块级作用域 | 无 | 有 |
| 允许重复声明 | 允许 | 不允许 |
| 全局对象属性 | 会挂到 window | 不会 |
| 闭包 for 循环问题 | 经典坑 | 完美解决 |
八、面试高频题合集(直接背)
- 输出是什么?
var a = 1;
function test(){
console.log(a);
var a = 2;
}
test(); // undefined
- 输出是什么?
let a = 1;
{
console.log(a);
let a = 2;
}
// ReferenceError
- 如何让 var 实现块级作用域?
// 经典解法:立即执行函数(IIFE)
for (var i = 0; i < 3; i++) {
(function(i){
setTimeout(() => console.log(i), 1000);
})(i);
}
- 为什么 function 声明也会提升,而且提升级别比 var 还高?
因为函数声明同时记录在变量环境 和 词法环境 中,提升最完整。
九、从“混乱”到“优雅”的进化之路
JavaScript 从一个 10 天的 KPI 项目,成长为今天最强大的编程语言之一,作用域机制的演进就是最好的缩影:
- ES5:简单粗暴的变量提升 → 无数坑
- ES6:优雅的词法环境 + 块级作用域 + TDZ → 现代前端基石
理解了执行上下文的“一国两制”和词法环境的“栈式结构”,你就真正掌握了 JavaScript 的灵魂。