V8 引擎不愿说的秘密:JS 作用域如何从 “ES5 裸奔” 升级到 ES6 “块级防护”?

80 阅读6分钟

一句话总结:
JS 的作用域,看似玄学,实则能用“宫斗剧 + 行李托运 + 机房机架”讲清楚。

如果你过去对 JS 的作用域、执行上下文、变量提升、块级作用域、暂时性死区感到疑惑,那你一定要看完这篇文章。保证你看完之后,看到 var/let/const 能像看三兄弟一样:这是混世魔王老大(var)、这是规规矩矩老二(let)、这是乖到不行的小妹(const)。


🏰 第一章:作用域到底是什么?看不见的“隐形围栏”

什么是作用域?

作用域 = 一种变量的“活动范围” + “领地意识”。
在什么地方声明,就能在什么地方“横着走”。

在 JS 世界中,有三种领地:

  • 全局作用域:整个地球都能访问,我就是王!
  • 函数作用域:我在我自己的小公寓里可以自由活动。
  • 块级作用域(ES6 引入) :我在我自己的卧室才安全。

如果你是从别的语言转过来的,例如 Java、Python,你会说:
——“块级作用域不是常识吗?这很正常啊。”

JS: “我以前没这个功能的啦……”

这就不得不谈一谈Javascript的黑历史。


⏳ 第二章:ES5 年代的 JS:一个仓促上线的 KPI 项目

JS 的诞生,用官方话讲:

10 天造出来的语言。

那 10 天里能干嘛?
写出:

  • 语法
  • 基础类型
  • 函数
  • 原型
  • 执行机制

你让我设计块级作用域?抱歉,我连宿舍都还没找好。

所以 ES5 时代 JS 是这样的:

  • 没有块级作用域
  • 变量全部“提升”到作用域顶端
  • var 乱飞
  • 函数和变量同名还能互相覆盖

于是你会看到各种经典迷惑行为:

console.log(a); // undefined
var a = 10;

你以为这是 JS 在耍帅?
实际上这是:

v8 在编译阶段帮你偷偷把 var 挪到作用域顶部了。

这也是为什么大家说:

👉变量提升是 JS 的设计缺陷之一。
👉但为了兼容历史包袱,只能一路背到今天。


🧠 第三章:执行上下文:作用域背后的“大总管”

想理解 JS 的作用域,我们必须引出一个关键角色:

执行上下文(Execution Context)
这是 JS 执行任何代码时必须创建的一个包装盒。

每个上下文里有两个重要领地:

名称存放内容专属行为
变量环境(Variable Environment)存 var 声明会进行变量提升
词法环境(Lexical Environment)存 let/const + 块级作用域存在暂时性死区、不可提升

一个用户看不到的 内部结构 大概像这样:

执行上下文
│
├── 变量环境  (var)
│      └── a = undefined(先创建)
│
└── 词法环境  (let/const)
       └── 并不是 undefined,而是 TDZ(暂时性死区)

这就解释了为什么这段代码会报错:

console.log(b); // ❌ ReferenceError
let b = 10;

因为 b 在词法环境的 TDZ(暂时性死区)
“你声明我之前,我都不会让你碰!”


🧱 第四章:块级作用域是怎么被“硬加进去”的?

ES6 必须兼容之前 ES5 的逻辑,又要支持现代语言都有的块级作用域。

怎么办?

V8 的解决方案非常优雅:用栈结构模拟每个块级作用域。

当执行到一个 {}

  1. 给词法环境 push 一个新的“块层”
  2. 在这一层中放 let / const
  3. 当块结束,词法环境被 pop 掉

于是代码:

let a = 1
{
    let a = 2
    console.log(a)
}
console.log(a)

在词法环境中大概长这样:

词法环境 (stack)
│
├── 全局层: a = 1
│
└── 块级层: a = 2(优先查这里)

输出:

2
1

块级作用域执行完,内部的 a 就从栈里 pop 出去了,外部永远访问不到。

漂亮!
现代化语言的能力就这样被“硬塞”进 JS 里。


🔥 第五章:ES5 vs ES6 —— 一国两制的诞生

为了兼容从 1995 年一路传下来的奇葩设计,JS 有了这样有趣的现象:

👉 var 属于“变量环境”

  • 会提升
  • 全局污染
  • 无块级作用域
  • 函数作用域

👉 let/const 属于“词法环境”

  • 不提升
  • 有暂时性死区
  • 有块级作用域
  • 每个 {} 都独立

这就是所谓的:

JS 执行上下文里的“一国两制”:
一个作用域,两个环境(VE + LE)。

也是为什么你会看到这种鬼畜差异:

var 的经典鬼故事

if (true) {
  var name = '大厂苗子'
}
console.log(name) // 输出:大厂苗子

let 的健康逻辑

if (true) {
  let name = '大厂苗子'
}
console.log(name) // ❌ ReferenceError

同样的代码,行为却完全不同。
原因你已经知道了:它们不在一个“环境”里。


🎭 第六章:作用域链:变量查找的路线图

当 JS 查找一个变量时,它会:

  1. 先查当前的作用域层(词法环境/变量环境)
  2. 如果没有,向外层找
  3. 一直找到全局
  4. 如果还没有,报错

这就是 作用域链(Scope Chain)

可以简单地理解为:

我 ↓
我爸 ↓
我爷爷 ↓
太爷 ↓
……

如果所有祖宗都没有这个变量,那就:

ReferenceError: xxx is not defined

💣 第七章:为什么大家说变量提升是设计缺陷?

因为它会导致各种迷惑和 bug:


❌ 1. 看起来变量还没声明,但已经拿来用了

console.log(a) // undefined
var a = 10

初学者一脸问号:这语言还能透视未来吗?


❌ 2. var 会污染全局 scope

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

你只是跑了个循环,它却给全局塞了一个变量。


❌ 3. 不同块级里的 var 会互相覆盖

if (true) var a = 1;
if (true) var a = 2;
console.log(a) // 2

写着写着:
“我是谁?我在哪?我变量怎么变了?”


🚀 第八章:现代 JS 是怎么让 var 和 let 和谐共存的?

全靠执行上下文的“二元结构”:

✔ var 放在变量环境(VE)

  • 会提升
  • 无块级作用域

✔ let/const 放在词法环境(LE)

  • 有块级作用域
  • 有暂时性死区
  • 不提升
  • 用栈结构维护嵌套块级作用域

换句话说:

JS 并没有抛弃 var,而是把它关在“老区”,
让 let/const 住进“新区”。
一国两制,各自安好。


🎯 第九章:你应该如何记住这些概念?

给你一个最通俗比喻:

var —— 住在“公共宿舍”的老大哥

  • 进去就占床位(提升)
  • 谁都能看到它(无块级作用域)
  • 还喜欢串寝(覆盖变量)

let —— 住单人间的好青年

  • 进房前不能访问(TDZ)
  • 房间说关门就关门(块级作用域)
  • 不允许和别人重名(不重复声明)

const —— 把门焊死的租客

  • 一旦入住不能换房间(不能重新赋值)

🧩 最终总结:5 张图带你彻底理解作用域

1. JS 执行代码前——创建执行上下文

执行上下文
│
├── 变量环境(var 提升)
└── 词法环境(let/const,存在 TDZ)

2. 遇到块级作用域 {}

词法环境.push(新的块层)

3. 块完成

词法环境.pop()

4. 查找变量

当前层 → 外层…… → 全局

5. var 依然保留,但被限制在变量环境里

let/const 才是新时代的主流


🎉 写在最后:JS 底层并不难,只是没人给你讲“正确的故事”

作用域通常被很多人讲得:

  • 抽象
  • 抽象
  • 还是抽象

但其实:

只要把执行上下文想象成房子、想象成栈、想象成小区
所有概念都会瞬间清晰。

如果你准备继续深入 JS 底层(比如原型链、闭包、事件循环、宏任务微任务),随时叫我,我可以继续给你写“掘金风格·底层通俗系列”