变量三兄弟与作用域迷宫

137 阅读5分钟

—— 小Dora 的 JavaScript 修炼日记 · Day 1

“如果你还不明白变量的本质,那你从来不知道 JavaScript 到底在搞啥鬼”
—— 来自一位被 TDZ 一切撤回的前端工程师

今天是小Dora修炼 JavaScript 的第一天,我们不开头讲 DOM,不搞计时器,不谈原型链,单刀直入讲清:

JavaScript 变量究竟是怎么在 V8 引擎中被创建、存储与访问的?

是时候揭开那一层编译器对我们刻意隐藏的底层真相了。


🧠 一、变量三兄弟的行为背后:来自 V8 的内存安排

🥷 1. var:作用域松散的“古早党”

当然可以,下面是一个更加严谨、符合 V8 实际内存模型的改写版本,同时保持技术趣味性与表达清晰性:


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

你以为这是语法糖?不,V8 引擎早在你执行前的编译阶段就已经偷偷给你干了几件事:

  • 构建了当前执行上下文,包括Variable Environment(变量环境)
  • 栈内存中为 name 分配了一个变量槽
  • 将变量 name 初始化为 undefined

此时的内存结构长这样:

[Stack - 执行上下文变量环境]
  name = undefined

到了执行阶段,当解释器执行到 var name = "小Dora" 时:

  • 字符串 "小Dora" 被存储到 堆内存(Heap)
  • name 变量在栈中的槽位更新为:指向堆内存的地址引用

执行后的内存结构如下:

[Stack - 执行上下文变量环境]
  name --> 0x001

[Heap]
  0x001: "小Dora"

所以变量提升其实是指“声明被提前,赋值没动”。也就是说:你人在,但灵魂还没上线。

👨‍💼 2. let:带着“访问许可证”的正规军

console.log(age); // ❌ ReferenceError
let age = 18;

你一访问 age,V8 立马给你爆头——为啥这么严格?

  • let 在创建阶段进入了所谓的 TDZ(暂时性死区)
  • 在 LexicalEnvironment 中,age = <uninitialized>
  • 只有执行过初始化之后,才算“可被访问”
[Stack]
  LexicalEnv = {
    age: <uninitialized>
  }

访问它?就是访问一个“不存在的合法引用”,当然 ReferenceError。

🛡️ 3. const:一旦绑定,绝不松口

const goal = "高级前端";
goal = "产品经理"; // ❌ TypeError

V8 在 const 初始化之后,会打上一个 只读锁(readonly),再也不能改。

但注意⚠️:

const config = { debug: true };
config.debug = false; // ✅ 这是允许的

因为 const 锁住的是栈上的指针地址,不是堆中对象的内容。

[Stack]
  config --> 0x003
[Heap]
  0x003 = { debug: true }

换人不行,换衣服可以。


🧬 二、执行上下文与作用域链:变量“在哪里”访问?

每次 JS 执行时,都会生成一个 执行上下文(EC) ,每个上下文会包含三个核心部分:

  1. VariableEnvironment(VE) :处理 var
  2. LexicalEnvironment(LE) :处理 let/const,形成作用域链
  3. ThisBinding:绑定当前执行上下文中的 this

示例:

function foo() {
  var x = 10;
  let y = 20;
}

编译阶段:

ExecutionContext (EC_foo): {
  VE: { x: undefined },
  LE: { y: <uninitialized> },
  this: undefined
}

varlet/const 分别存储在不同“柜子”里:一个老旧但包容(VE),一个严格到近乎傲慢(LE)。


📦 三、栈与堆:变量在内存中的真实归宿

数据类型存储位置示例
基本类型栈(stack)let a = 1
引用类型栈存引用 → 堆let obj = { name: '小Dora' }
函数对象function foo() {}

在 V8 中,基本类型直接放在栈上,引用类型放堆里,栈中只保留指针

举个例子:

let a = 1;
let obj = { name: "Dora" };

内存结构如下:

[Stack]
  a = 1
  obj --> 0x100

[Heap]
  0x100 = { name: "Dora" }

🌀 四、生命周期三段论:变量不是瞬间就有

阶段var 的行为let/const 的行为
创建阶段注册变量名注册变量名 + 设为 uninitialized
初始化阶段设为 undefined无任何默认值(此时 TDZ)
执行阶段正常赋值/使用初始化 + 赋值/使用

⚠️ 访问 TDZ 中的变量,本质上是访问一个还未分配内存值的“挂名标识”,V8 会直接拒绝。


🛠️ 五、实战演练:你以为你懂,其实只是表象

示例 1:var 穿越块作用域

{
  var foo = "JS Ninja";
}
console.log(foo); // ✅ "JS Ninja"

V8:这不是块作用域,这是空气。

示例 2:let/const 的范围防御

{
  let bar = "闭环之力";
}
console.log(bar); // ❌ ReferenceError

V8:你出了花括号,就别想碰它。

示例 3:变量提升细节复盘

function test() {
  console.log(a); // undefined
  console.log(b); // ReferenceError
  var a = 1;
  let b = 2;
}
test();

V8 在创建阶段已经:

VE = { a: undefined }
LE = { b: <uninitialized> }

所以:a 可以访问但没值;b 直接报错。


🧪 六、大厂面试真题你扛得住?

💥 Q1:

{
  var a = 1;
  let b = 2;
}
console.log(a); // ?
console.log(b); // ?

✅ 输出:

1
ReferenceError

💥 Q2:

const person;
person = { name: "Dora" };

✅ 输出:SyntaxError: Missing initializer in const declaration

const 必须“出生即初始化”,哪怕你说“我迟点起名字”,也不行。


🧾 七、一图回顾:V8 内部变量管理模型

[栈 Stack]
  fooEC = {
    VE: { varA: undefined },
    LE: { letB: <uninitialized> },
    this: undefined
  }

[堆 Heap]
  0x101 = { name: "小Dora" }
  0x201 = () => {} // 带 [[Environment]] 指针

变量存储结构清晰明了:栈快速回收,堆留长久数据,函数闭包保环境。


🧘 八、Day 1 修炼总结

特性对比varletconst
提升行为✅ 声明提升❌(TDZ)❌(TDZ)
存储位置栈指针 → 堆栈指针 → 堆栈指针 → 堆
可否重复声明✅ 可以❌ 报错❌ 报错
可否重新赋值✅ 可以✅ 可以❌ 不可

📌 变量声明,不只是写 letvarconst,它是整个 V8 引擎执行流程的入口级结构设计

🔚 尾声:走出变量迷宫,踏入执行上下文

第一天结束了,小Dora已经理解:

  • 变量三兄弟不仅语法不同,运行机制也不同
  • TDZ、作用域和变量提升,是理解 JS 的起点
  • 每一个变量都有生命周期,控制它就是掌控 JS

📌 下一站:词法作用域、执行上下文与闭包三连击 —— 这是所有中高级面试绕不过去的必杀技。

如果你也在学习 JavaScript,欢迎继续追更 《小Dora 的 JS 修炼日记》
我们不只写代码,我们要理解代码“为什么这么写”。