🚀 JavaScript 内存大揭秘:从"栈堆搬家"到"闭包时空胶囊"
第一章:舞台搭建 ------ 内存的三大分区
在代码运行之前,JavaScript 引擎先画好了三块地皮。请看这张图,这是所有故事发生的物理地基:

- 🟠 代码空间 (Code Space):存放我们的剧本(源代码)。
- 🔴 栈空间 (Stack) :"临时更衣室" 。
- 特点:进出极快,空间小,自动整理。
- 住谁?函数执行的上下文 、基本数据类型(数字、布尔值等)。
- 规则:后进先出(LIFO),函数执行完,里面的东西立马被清空。
- 🔵 堆空间 (Heap) :"大型仓库" 。
- 特点:空间大,存取稍慢,需要保洁员(垃圾回收器 GC)定期打扫。
- 住谁?对象、数组、函数等复杂的大件物品。
💡 核心隐喻:
- 栈 是演员手里的提词卡(写着简单的数字或地址)。
- 堆 是后台巨大的道具库(放着复杂的布景和道具)。
- 演员(变量)手里通常只拿着一张写有道具编号的卡片(引用地址),而不是直接把道具扛在肩上。
第二章:基本类型的"独立副本" ------ 深度解析 1.js
让我们先看 1.js 的代码,看看它在栈空间里是怎么"变魔术"的。
📜 代码剧本 (1.js)
javascript
function foo() {
var a = 1; // 步骤 A
var b = a; // 步骤 B
a = 2; // 步骤 C
console.log(a); // 输出 2
console.log(b); // 输出 1 <-- 为什么 b 没变?
}
foo();
🎬 内存现场直播
步骤 A:var a = 1;
引擎在栈空间 开辟了一个格子,贴上标签 a,里面直接放入数字 1。
- 栈状态 :
[ a: 1 ] - 堆状态:空(基本类型不住堆)
步骤 B:var b = a; (关键瞬间!)
这是新手最容易误解的地方。
-
错误理解 :
b和a绑定了,a变b也变。 -
真相 :引擎在栈空间 又开辟了一个全新 的格子,贴上标签
b。它读取a格子里的值(也就是1),然后复制 了一份放到b的格子里。 -
栈状态 :
text[ a: 1 ] [ b: 1 ] <-- 这是一个独立的副本! -
此时,a 和 b 毫无关系,只是数值碰巧相同。
步骤 C:a = 2;
引擎找到标签 a 的格子,把里面的 1 擦掉,写上 2。
-
栈状态 :
text[ a: 2 ] <-- 只有这里变了 [ b: 1 ] <-- b 毫发无损,因为它存的是独立的副本
🏁 结局
console.log(a)-> 读到2。console.log(b)-> 读到1。
🧠 记忆口诀 :基本类型是"复印机"。
b = a是把a的内容复印了一份给b。以后a怎么改,跟b手里的复印件没关系。
第三章:引用类型的"共享地址" ------ 深度解析 2.js
现在难度升级,看看 2.js 中的对象。这时候,堆空间登场了。
📜 代码剧本 (2.js)
javascript
function foo() {
var a = {name: "极客时间"}; // 步骤 A
var b = a; // 步骤 B
a.name = '极客邦'; // 步骤 C
console.log(a);
console.log(b); // 输出什么?居然也变了?
}
foo();
🎬 内存现场直播
步骤 A:var a = {name: "极客时间"};
- 堆空间行动 :引擎发现是个对象(大件物品),不能在栈里直接放。于是它在堆空间 申请了一块地盘(假设地址是
1001),把{name: "极客时间"}这个对象存进去。 - 栈空间行动 :在栈里创建变量
a。但是a里面不存对象本身 ,而是存那个对象的门牌号(地址)1001。
- 栈状态 :
[ a: 1001 (地址) ] - 堆状态 :
地址 1001 -> { name: "极客时间" }
步骤 B:var b = a; (最关键的时刻!)
-
动作 :引擎在栈里创建变量
b。它读取a里的内容。 -
注意 :
a里的内容是1001(地址)。所以,引擎把1001复制 给了b。 -
结果 :
a和b现在都拿着同一张写着1001的纸条。它们指向同一个堆内存地址。 -
栈状态:
text[ a: 1001 ] \ +--> 指向堆里的同一个对象 [ b: 1001 ] / -
堆状态 :
地址 1001 -> { name: "极客时间" }
步骤 C:a.name = '极客邦';
-
动作 :引擎通过
a找到地址1001,冲进堆空间 ,把那个对象里的name属性改成了'极客邦'。 -
关键点 :它修改的是堆里的实物,而不是栈里的地址。
-
堆状态更新 :
地址 1001 -> { name: "极客邦" }(实物被改了!)
🏁 结局
console.log(a):拿着地址1001去堆里看 -> 看到{ name: "极客邦" }。console.log(b):拿着地址1001去堆里看 -> 还是看到{ name: "极客邦" }。
🧠 记忆口诀 :引用类型是"遥控器"。
a和b是两个不同的遥控器(栈里的变量)。- 但它们都对着同一台电视机(堆里的对象)。
- 你用
a遥控器换了台(修改属性),b遥控器看到的画面自然也跟着变了。
第四章:闭包的"时空胶囊" ------ 结合图片深度拆解
为什么函数执行完了,里面的变量还能被记住?这就是闭包的魔法。我们结合您提供的后三张图来还原这个过程。
场景设定
javascript
function foo() {
var myName = "极客时间";
var test1 = 1;
function inner() {
var test2 = 2;
console.log(myName); // 这里的 myName 从哪来?
}
return inner; // 把内部函数扔出去
}
var bar = foo(); // foo 执行完了,按理说它的变量该消失了
bar(); // 但这里依然能打印 "极客时间"
第一阶段:函数执行中
当 foo() 正在运行时:
- 调用栈 (Call Stack) 压入了一个
foo的执行上下文。 - 变量环境 里记录了:
myName: "极客时间"test1: 1inner: 函数定义(包含了一个秘密武器:对外部作用域的引用)
- 此时一切正常,
myName就安稳地待在foo的栈帧里。
第二阶段:返回与引用的建立
这是最神奇的一步!
foo函数执行结束,按常理,它的执行上下文应该从调用栈 弹出,里面的myName应该被销毁。- 但是! 因为
inner函数(现在赋值给了全局变量bar)在定义时,偷偷通过作用域链 抓住了foo的变量环境。 - 内存迁移 :
- 原本应该在栈里随函数结束而消失的
myName和test1,因为被inner引用了,引擎被迫将它们从栈空间"转移"或"保留"在堆空间中(或者说,包含这些变量的整个作用域对象被移到了堆上持久化)。 - 如上图所示,
clourse(foo)(即inner) 在栈里,但它手里紧紧攥着一个地址1003。 - 地址
1003指向堆空间里的一个对象,里面赫然躺着{ myName: "极客时间", test1: 1 }。
- 原本应该在栈里随函数结束而消失的
第三阶段:调用闭包
当我们调用 bar() (即 inner) 时:
- 引擎创建
inner的执行上下文。 - 代码遇到
console.log(myName)。 - 引擎在当前上下文没找到
myName。 - 它顺着作用域链 (那个秘密武器),找到了堆里地址
1003对应的环境。 - 成功读取:"极客时间"。
🧠 闭包本质总结 : 闭包不是某种特殊的语法,而是函数与其词法环境的组合。
- 普通函数:用完即走,栈帧清空,数据消失。
- 闭包函数 :因为"有人"(外部引用)还需要它内部的变量,所以引擎不敢清空栈帧,而是把这些变量打包扔到堆里长期保存,直到没人再需要这个函数为止。
- 代价 :这些变量会一直占用内存,直到
bar = null断开引用,垃圾回收器才会来清理。
第五章:一图胜千言 ------ 总结对比
为了让您彻底清晰,我们把刚才的分析浓缩成一张对比表:
| 特性 | 基本类型 (1.js) | 引用类型 (2.js) | 闭包 (5.html/6.html) |
|---|---|---|---|
| 存储位置 | 只在栈 | 栈存地址,堆存实体 | 变量被强行保留在堆 |
| 赋值行为 | 值拷贝 (复印文件) | 引用拷贝 (复制遥控器) | 作用域捕获 (带走整个房间) |
| 修改影响 | 互不影响 | 互相影响 (改的是同一份数据) | 内部函数可读写外部私有变量 |
| 生命周期 | 函数结束即销毁 | 对象无引用时被 GC 回收 | 比定义它的函数活得更久 |
| 形象比喻 | 两个独立的苹果 | 两个人看同一个投影 | 把家里的家具搬到了公共仓库 |
💡 给开发者的终极建议
- 处理基本类型:放心大胆地赋值,不用担心改了一个影响另一个。
- 处理对象/数组 :小心!
b = a之后,你以为你在操作b,其实你可能在修改a的数据。如果需要独立副本,请使用扩展运算符[...a]或Object.assign进行深拷贝/浅拷贝。 - 使用闭包 :
- 好处:创造私有变量,模拟类,函数柯里化。
- 风险 :如果不小心在闭包里引用了巨大的 DOM 节点或大对象,且长期不释放,会导致内存泄漏。
- 解决 :不需要时,手动将引用置为
null(bar = null),告诉垃圾回收器"可以打扫了"。
希望这次结合内存动态流转 和生活化比喻的讲解,能让您对 JavaScript 的内存机制和闭包有透彻的理解!如果还有哪个环节觉得不够直观,请随时告诉我,我们可以针对那个点继续深挖。