第1章 内存泄漏基础

什么是内存泄漏

内存泄漏,说白了就是程序申请了一块内存,用完之后忘了还回去。系统认为这块内存还被占着,没法回收再利用。久而久之,可用内存越来越少,页面就开始卡顿,甚至崩溃。

我记得刚做前端那会儿,遇到一个奇怪的问题:一个列表页,用户不停地滚动加载,十几分钟后页面就变得巨卡。当时我还以为是DOM太多了,结果一看内存快照——好家伙,几万个被删除的节点仍然挂在内存里,根本没有被回收。这就是典型的泄漏。

你想想看,浏览器GC(垃圾回收)并不是万能的。它只能回收那些“不可达”的对象。但如果你的代码里还保留着对某个对象的引用,GC就认为它还在用,不会释放。这个对象就一直赖在内存里不走。

内存生命周期:分配 → 使用 → 释放

要理解泄漏,先得搞懂内存是怎么流转的。我习惯用一个简单的三角模型来记:

  • 分配:创建变量、对象、DOM元素、闭包时,JS引擎会从堆或栈中划出一块内存。
  • 使用:读写变量、调用函数、操作DOM等,都在使用这块内存。
  • 释放:当没有任何引用指向这块内存时,GC会在合适时机回收它。

正常的生命周期是完整的。泄漏,就发生在最后一步——该释放的时候没释放。如果一个对象已经不需要了,但某个全局变量、闭包或者事件监听器还抱着它不放,那它就永远“活”着。

阶段正常行为泄漏行为
分配按需分配频繁分配大对象
使用用后即断引用长期保持无用引用
释放GC正常回收GC无法标记为垃圾

常见泄漏类型归纳总结

在我排查过的项目中,泄漏无外乎下面这几类。我把它们归纳成一个表格,方便你对照检查:

泄漏类型典型场景危害程度
全局变量泄漏未声明的变量挂到 window 上中
闭包泄漏闭包持有外部函数的大对象引用高
DOM 泄漏移除的 DOM 元素还被 JS 引用高
事件监听器泄漏组件销毁时未移除监听器中高
定时器/回调泄漏setInterval 或 setTimeout 未清除高
Map/Set 使用不当对象作为 key 但未手动删除中
WebSocket/Observer 泄漏未正确关闭连接或取消订阅高

这里我特别想提一下闭包泄漏。很多同学觉得闭包很酷,但用起来不注意就会埋坑。我曾经在项目里遇到过:一个大型表格组件,每次渲染都生成一个新的排序函数,这个函数里引用了整个表格的数据数组。结果就是每一个排序列都带着一份完整数据引用,页面切换后这些闭包还待在内存里,导致内存只涨不降。

另一个常踩的坑是DOM 泄漏。比如你通过 document.getElementById 拿到一个元素,然后删除了这个元素,但变量里还存着它的引用。这个 DOM 对象就不会被回收——即使它已经不在文档流中了。

// 错误示范:DOM 已移除,但变量还引用着
let oldDiv = document.getElementById('myDiv');
document.body.removeChild(oldDiv);
// 此时 oldDiv 仍然指向一个被移除的 DOM 节点,内存泄漏

// 正确做法:移除引用
oldDiv = null;

还有事件监听器泄漏。特别是单页应用,组件挂载时绑定了监听器,卸载时忘了解绑。我在做一个长列表优化时发现,每滚动一次就新增一批监听器,几十次后监听器数量破万,页面操作响应直接变成“龟速”。

嗯,这里要注意:不是所有对象都不释放才算泄漏。有些对象被泄露了,但总量小,影响不大。量变引起质变——当泄漏对象积累到一定规模,GC 频率升高、标记时间变长,就会出现明显的渲染卡顿。这就是我们做性能优化的核心目标之一:控制内存水位线。

一句话总结:内存泄漏 = “该回收的对象因为残存的引用而无法回收”。
实战小技巧:排查泄漏时,我习惯先看全局变量——打开 Chrome DevTools Memory 面板,拍一次堆快照,再操作一遍页面,再拍第二次,对比新增的对象里有没有不该存在的。
避坑指南:我曾经花了两天时间排查一个泄漏,最后发现是 console.log 打出了一个大对象。因为浏览器控制台会保持对被打印对象的引用(为了展示),导致对象无法被回收。所以线上代码一定要移除或条件编译掉 console.log。

知识体系一览

下面这张图把本章的核心逻辑串在了一起,你可以把它当作记忆地图:

内存泄漏基础·知识框架 分配 使用 正常释放 泄漏 全局变量泄漏 闭包泄漏 DOM泄漏 更多类型 泄漏 = 内存未按预期释放,导致可用内存持续减少