从变量提升到块级作用域:JavaScript 作用域的进化史

144 阅读5分钟

“JavaScript 的设计,是一场在时间与商业竞争夹缝中诞生的妥协。”

你有没有遇到过这样的代码:

console.log(name); // undefined
var name = "ljm";

为什么 nameundefined?明明我们还没给它赋值。
这背后,是 JavaScript 一个古老而深邃的设计——变量提升(Hoisting)

今天,我们就来深入探讨:
变量提升是如何产生的?它为何被诟病为“缺陷”?ES6 又如何用“一国两制”的方式解决这个问题?


一、变量提升:JavaScript 的“先天不足”

1.1 什么是变量提升?

在 JavaScript 中,使用 var 声明的变量会在编译阶段被“提升”到当前作用域的顶部,并初始化为 undefined

console.log(myname); // undefined
var myname = "路明非";

这段代码等价于:

var myname; // 提升到顶部
console.log(myname); // undefined
myname = "路明非";

✅ 这就是变量提升的本质:声明提前,赋值不提前

1.2 为什么会有变量提升?

这要回到 JavaScript 的诞生背景。

  • 1995年,Brendan Eich 在 Netscape 公司仅用 10 天 设计了 JavaScript。
  • 目标是:让网页动起来,不是构建复杂系统。
  • 为了快速实现,设计上做了大量简化。

其中最核心的一条:不支持块级作用域

没有块级作用域,意味着 {} 内部的变量无法隔离,于是 JS 团队决定:

把所有变量都“提升”到函数顶部,统一管理。

这样做的好处是:

  • 实现简单
  • 执行效率高
  • 避免作用域嵌套带来的复杂性

但代价是:逻辑混乱、容易出错


二、变量提升的“副作用”:坑多如麻

2.1 变量被意外覆盖

var name = "ljm";
function showName() {
    console.log(name); // undefined
    if (true) {
        var name = "大厂的苗子"
    }
    console.log(name); // "大厂的苗子"
}
showName();

为什么第一次打印是 undefined?因为 var name 被提升到了函数顶部,导致外部的 name 被“遮蔽”。

更糟糕的是,if 块内定义的变量会影响整个函数作用域,这是违反直觉的。

2.2 块级作用域缺失

for(var i = 0; i < 3; i++) {
    console.log(i);
}
console.log(i); // 3

i 没有被限制在循环内,而是泄露到了全局。这在现代开发中是灾难性的。


三、ES6 的救赎:let/const 与“一国两制”

3.1 ES6 引入块级作用域

let name = "ljm";
{
    let name = "大厂的苗子";
    console.log(name); // "大厂的苗子"
}
console.log(name); // "ljm"

letconst 支持块级作用域,变量只在 {} 内有效。

但这只是表象。真正的秘密在于:执行上下文的内部结构发生了变化


四、执行上下文:变量环境 vs 词法环境

JavaScript 的执行上下文(Execution Context)包含两个核心部分:

环境用途存储内容
变量环境(Variable Environment)存放 var 声明的变量提前声明,初始为 undefined
词法环境(Lexical Environment)存放 let/const 声明的变量支持块级作用域,有栈结构

🌟 关键点:var 和 let 被分治管理!

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); // ReferenceError: d is not defined
}
foo();

图解:foo 函数的执行上下文

67F3FB8C-13CF-4F1A-8934-AC15CCA9358C.png

foo函数的执行上下文

第一步:编译阶段(创建执行上下文)

  • 变量环境a = 1c = undefined
  • 词法环境b = undefined(外层),b = undefined, d = undefined(内层)

⚠️ 注意:let b = 2 会被放入词法环境的外层栈帧,而 let b = 3 放入内层栈帧

第二步:执行阶段

当执行到 {} 内部时:

  • 词法环境压入新栈帧b = 3d = 5
  • 查找变量时,先从栈顶开始(最近的作用域)
  • 找不到再向上查找

🔍 console.log(a) → 查找变量环境 → a = 1
🔍 console.log(b) → 查找栈顶 → b = 3

执行完 {} 后,栈帧弹出b=3, d=5 不再可访问。


五、“暂时性死区”:避免错误的利器

let name = "ljm";
{
    console.log(name); // ReferenceError: Cannot access 'name' before initialization
    let name = "大厂的苗子";
}

let 声明的变量在声明之前不可访问,这就是“暂时性死区(Temporal Dead Zone, TDZ) ”。

为什么需要 TDZ?

防止在变量未初始化前就使用它,比如:

let x = y + 1;
let y = 10; // ❌ 错误!y 尚未定义

TDZ 确保了变量必须在声明后才能使用,提升了代码的安全性。


六、历史的回响:为何保留 var

虽然 let/const 更安全,但 JavaScript 必须向下兼容

  • 海量旧代码依赖 var
  • var 的行为已深入人心(尽管是坏习惯)

所以 ES6 采取了“一国两制”策略:

特性varlet/const
提升✅ 是❌ 否(但声明提前)
块级作用域❌ 否✅ 是
暂时性死区❌ 否✅ 是
重复声明✅ 允许❌ 禁止

💡 本质是:用不同的机制处理不同需求,既保留兼容性,又引入现代特性。


七、总结:作用域的演进之路

时代作用域变量提升块级作用域问题
ES5函数作用域✅ 是❌ 否变量污染、逻辑混乱
ES6+函数 + 块级var: ✅;let: ❌✅ 是安全、清晰

🧠 核心思想:

  • 变量提升是历史产物,源于早期设计的简化。
  • 块级作用域是现代需求,要求更严格的变量控制。
  • ES6 通过“变量环境”和“词法环境”的分离,实现了两者共存

八、图注解析

1:编译阶段的执行上下文

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

  • var a = 1 → 变量环境:a = 1
  • let b = 2 → 词法环境:b = undefined(声明提前)
  • var c = 4 → 变量环境:c = undefined
  • let d = 5 → 词法环境:d = undefined

✅ 此时变量尚未赋值,但已存在。


2:执行阶段的变量查找

  • console.log(a) → 查找变量环境 → a = 1
  • console.log(b) → 查找词法环境栈顶 → b = 3

🔁 词法环境像一个栈,每次进入块级作用域就压入新帧,退出时弹出。


3:词法环境的栈结构

  • 外层:b = undefined
  • 内层:b = 3d = 5
  • 执行完成后,内层帧被销毁,d 不再可访问。

九、写在最后

JavaScript 的作用域系统,是一段技术与现实博弈的历史

从最初的“快速上线”,到如今的“严谨规范”,我们看到了语言的成熟。

🌱 好的设计,不一定完美,但一定是在不断修正中前行

当你下次看到 letvar 的区别时,不妨想想:

这不只是语法差异,更是一代开发者对“安全”与“兼容”的深刻思考