第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 频率升高、标记时间变长,就会出现明显的渲染卡顿。这就是我们做性能优化的核心目标之一:控制内存水位线。
Memory 面板,拍一次堆快照,再操作一遍页面,再拍第二次,对比新增的对象里有没有不该存在的。
console.log 打出了一个大对象。因为浏览器控制台会保持对被打印对象的引用(为了展示),导致对象无法被回收。所以线上代码一定要移除或条件编译掉 console.log。
知识体系一览
下面这张图把本章的核心逻辑串在了一起,你可以把它当作记忆地图: