🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

0 阅读8分钟

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

第一章:舞台搭建 —— 内存的三大分区

在代码运行之前,JavaScript 引擎先画好了三块地皮。请看这张图,这是所有故事发生的物理地基

c2e28f0b62e932380333c67696ea1599.jpg

  1. 🟠 代码空间 (Code Space):存放我们的剧本(源代码)。
  2. 🔴 栈空间 (Stack)“临时更衣室”
    • 特点:进出极快,空间小,自动整理。
    • 住谁?函数执行的上下文基本数据类型(数字、布尔值等)。
    • 规则:后进先出(LIFO),函数执行完,里面的东西立马被清空。
  3. 🔵 堆空间 (Heap)“大型仓库”
    • 特点:空间大,存取稍慢,需要保洁员(垃圾回收器 GC)定期打扫。
    • 住谁?对象、数组、函数等复杂的大件物品。

💡 核心隐喻

  • 是演员手里的提词卡(写着简单的数字或地址)。
  • 是后台巨大的道具库(放着复杂的布景和道具)。
  • 演员(变量)手里通常只拿着一张写有道具编号的卡片(引用地址),而不是直接把道具扛在肩上。

第二章:基本类型的“独立副本” —— 深度解析 1.js

让我们先看 1.js 的代码,看看它在栈空间里是怎么“变魔术”的。

📜 代码剧本 (1.js)

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; (关键瞬间!)

这是新手最容易误解的地方。

  • 错误理解ba 绑定了,ab 也变。
  • 真相:引擎在栈空间又开辟了一个全新的格子,贴上标签 b。它读取 a 格子里的值(也就是 1),然后复制了一份放到 b 的格子里。
  • 栈状态
    [ a: 1 ]
    [ b: 1 ]  <-- 这是一个独立的副本!
    
  • 此时,a 和 b 毫无关系,只是数值碰巧相同。
步骤 C:a = 2;

引擎找到标签 a 的格子,把里面的 1 擦掉,写上 2

  • 栈状态
    [ 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)

function foo() {
    var a = {name: "极客时间"};  // 步骤 A
    var b = a;                    // 步骤 B
    a.name = '极客邦';            // 步骤 C
    console.log(a); 
    console.log(b);               // 输出什么?居然也变了?
}
foo();

🎬 内存现场直播

步骤 A:var a = {name: "极客时间"};
  1. 堆空间行动:引擎发现是个对象(大件物品),不能在栈里直接放。于是它在堆空间申请了一块地盘(假设地址是 1001),把 {name: "极客时间"} 这个对象存进去。
  2. 栈空间行动:在栈里创建变量 a。但是 a 里面不存对象本身,而是存那个对象的门牌号(地址) 1001
  • 栈状态[ a: 1001 (地址) ]
  • 堆状态地址 1001 -> { name: "极客时间" }
步骤 B:var b = a; (最关键的时刻!)
  • 动作:引擎在栈里创建变量 b。它读取 a 里的内容。

  • 注意a 里的内容是 1001(地址)。所以,引擎把 1001 复制给了 b

  • 结果ab 现在都拿着同一张写着 1001 的纸条。它们指向同一个堆内存地址。

  • 栈状态

    [ a: 1001 ]  \
                  +--> 指向堆里的同一个对象
    [ b: 1001 ]  /
    
  • 堆状态地址 1001 -> { name: "极客时间" }

步骤 C:a.name = '极客邦';
  • 动作:引擎通过 a 找到地址 1001,冲进堆空间,把那个对象里的 name 属性改成了 '极客邦'

  • 关键点:它修改的是堆里的实物,而不是栈里的地址。

  • 堆状态更新地址 1001 -> { name: "极客邦" } (实物被改了!)

🏁 结局
  • console.log(a):拿着地址 1001 去堆里看 -> 看到 { name: "极客邦" }
  • console.log(b):拿着地址 1001 去堆里看 -> 还是看到 { name: "极客邦" }

🧠 记忆口诀引用类型是“遥控器”。

  • ab 是两个不同的遥控器(栈里的变量)。
  • 但它们都对着同一台电视机(堆里的对象)。
  • 你用 a 遥控器换了台(修改属性),b 遥控器看到的画面自然也跟着变了。

第四章:闭包的“时空胶囊” —— 结合图片深度拆解

为什么函数执行完了,里面的变量还能被记住?这就是闭包的魔法。我们结合您提供的后三张图来还原这个过程。

场景设定

function foo() {
    var myName = "极客时间";
    var test1 = 1;
    
    function inner() {
        var test2 = 2;
        console.log(myName); // 这里的 myName 从哪来?
    }
    
    return inner; // 把内部函数扔出去
}

var bar = foo(); // foo 执行完了,按理说它的变量该消失了
bar();           // 但这里依然能打印 "极客时间"

第一阶段:函数执行中

foo() 正在运行时:

  1. 调用栈 (Call Stack) 压入了一个 foo 的执行上下文。
  2. 变量环境里记录了:
    • myName: "极客时间"
    • test1: 1
    • inner: 函数定义(包含了一个秘密武器:对外部作用域的引用
  3. 此时一切正常,myName 就安稳地待在 foo 的栈帧里。

第二阶段:返回与引用的建立

这是最神奇的一步!

  1. foo 函数执行结束,按常理,它的执行上下文应该从调用栈弹出,里面的 myName 应该被销毁。
  2. 但是! 因为 inner 函数(现在赋值给了全局变量 bar)在定义时,偷偷通过作用域链抓住了 foo 的变量环境。
  3. 内存迁移
    • 原本应该在栈里随函数结束而消失的 myNametest1,因为被 inner 引用了,引擎被迫将它们从栈空间“转移”或“保留”在堆空间中(或者说,包含这些变量的整个作用域对象被移到了堆上持久化)。
    • 如上图所示,clourse(foo) (即 inner) 在栈里,但它手里紧紧攥着一个地址 1003
    • 地址 1003 指向堆空间里的一个对象,里面赫然躺着 { myName: "极客时间", test1: 1 }

第三阶段:调用闭包

当我们调用 bar() (即 inner) 时:

  1. 引擎创建 inner 的执行上下文。
  2. 代码遇到 console.log(myName)
  3. 引擎在当前上下文没找到 myName
  4. 它顺着作用域链(那个秘密武器),找到了堆里地址 1003 对应的环境。
  5. 成功读取:"极客时间"。

🧠 闭包本质总结: 闭包不是某种特殊的语法,而是函数与其词法环境的组合

  • 普通函数:用完即走,栈帧清空,数据消失。
  • 闭包函数:因为“有人”(外部引用)还需要它内部的变量,所以引擎不敢清空栈帧,而是把这些变量打包扔到堆里长期保存,直到没人再需要这个函数为止。
  • 代价:这些变量会一直占用内存,直到 bar = null 断开引用,垃圾回收器才会来清理。

第五章:一图胜千言 —— 总结对比

为了让您彻底清晰,我们把刚才的分析浓缩成一张对比表:

特性基本类型 (1.js)引用类型 (2.js)闭包 (5.html/6.html)
存储位置只在栈栈存地址,堆存实体变量被强行保留在堆
赋值行为值拷贝 (复印文件)引用拷贝 (复制遥控器)作用域捕获 (带走整个房间)
修改影响互不影响互相影响 (改的是同一份数据)内部函数可读写外部私有变量
生命周期函数结束即销毁对象无引用时被 GC 回收比定义它的函数活得更久
形象比喻两个独立的苹果两个人看同一个投影把家里的家具搬到了公共仓库

💡 给开发者的终极建议

  1. 处理基本类型:放心大胆地赋值,不用担心改了一个影响另一个。
  2. 处理对象/数组:小心!b = a 之后,你以为你在操作 b,其实你可能在修改 a 的数据。如果需要独立副本,请使用扩展运算符 [...a]Object.assign 进行深拷贝/浅拷贝
  3. 使用闭包
    • 好处:创造私有变量,模拟类,函数柯里化。
    • 风险:如果不小心在闭包里引用了巨大的 DOM 节点或大对象,且长期不释放,会导致内存泄漏
    • 解决:不需要时,手动将引用置为 null (bar = null),告诉垃圾回收器“可以打扫了”。

希望这次结合内存动态流转生活化比喻的讲解,能让您对 JavaScript 的内存机制和闭包有透彻的理解!如果还有哪个环节觉得不够直观,请随时告诉我,我们可以针对那个点继续深挖。