一文 + 6 张图彻底弄懂:变量提升、暂时性死区、块级作用域的底层真相

81 阅读6分钟

从“变量提升”到“块级作用域”:一文彻底搞懂 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 引擎)运行代码分为两个阶段:

  1. 编译阶段:创建执行上下文(Execution Context)
  2. 执行阶段:真正运行代码

为了让大家更直观地感受两者的区别,先上第一张经典对比图:

362dd525cb538c5bca0f3410039fbe99.png

注意!这里有两个环境!

  • 变量环境:只存放 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 的源头。

四、变量提升的两大罪状

  1. 变量容易被意外覆盖(变量遮蔽失效)
  2. 本该销毁的变量没有销毁(内存泄漏隐患)
// 罪状示范
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 函数刚创建时的执行上下文:

42b8dd4b68c20dced877cdb165c25e9c.png

  • var a、var c 已经被提升到 变量环境中,初始值为 undefined
  • 所有 let/const 变量(包括块里的)都已经被“登记”到词法环境中,但处于 未初始化 状态 → 这就是暂时性死区(TDZ)

执行到块作用域前一刻

执行到块前:

4dbe56f493f1b0c76e777182ae016b68.png

外层 let b = 2 已经完成赋值,退出 TDZ。

进入块级作用域的瞬间(最精彩的一幕!)

进入块级作用域:

66d5fd40188045f7a7f9b298fcf0e744.jpg

关键来了! 每进入一个 {} 块,JS 引擎就在词法环境中压入一个新的环境记录(栈结构):

  • 块内的 let b = 3、let d = 5 放在栈顶
  • var c = 4 依然提升到最外层的 变量环境

这才是 ES6 真正实现块级作用域的底层原理!

图5:块内打印时变量查找路径

块内查找变量:

47fdfe7f7c7b225ff511a680d9c502d6.png

查找顺序:

  1. 当前块的词法环境 → 找到 b = 3
  2. 外层词法环境 → b = 2
  3. 变量环境 → 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 声明这个变量名,从块开始到声明语句之前,这段区域就是“死区”,访问会直接抛错。

这其实是故意设计出来的“保护机制”,防止你使用尚未初始化的变量。

七、经典对比表(建议收藏)

特性varlet/const
是否提升是(声明+初始化为undefined)是(只提升声明,不初始化)
暂时性死区
块级作用域
允许重复声明允许不允许
全局对象属性会挂到 window不会
闭包 for 循环问题经典坑完美解决

八、面试高频题合集(直接背)

  1. 输出是什么?
var a = 1;
function test(){
    console.log(a);
    var a = 2;
}
test(); // undefined
  1. 输出是什么?
let a = 1;
{
    console.log(a);
    let a = 2;
}
// ReferenceError
  1. 如何让 var 实现块级作用域?
// 经典解法:立即执行函数(IIFE)
for (var i = 0; i < 3; i++) {
    (function(i){
        setTimeout(() => console.log(i), 1000);
    })(i);
}
  1. 为什么 function 声明也会提升,而且提升级别比 var 还高?

因为函数声明同时记录在变量环境 和 词法环境 中,提升最完整。

九、从“混乱”到“优雅”的进化之路

JavaScript 从一个 10 天的 KPI 项目,成长为今天最强大的编程语言之一,作用域机制的演进就是最好的缩影:

  • ES5:简单粗暴的变量提升 → 无数坑
  • ES6:优雅的词法环境 + 块级作用域 + TDZ → 现代前端基石

理解了执行上下文的“一国两制”和词法环境的“栈式结构”,你就真正掌握了 JavaScript 的灵魂。