学完这篇你能照着 Chrome DevTools 的流程,把「页面越用越卡、内存只涨不跌」的 JavaScript 内存泄漏定位到具体代码行。
第一步:先确认到底是不是真泄漏
打开页面,按 F12 打开 DevTools,切到 Performance 面板,勾上顶部的 Memory 复选框,点左上角录制按钮,操作 30 秒后停止。
看生成的 JS Heap 曲线:正常情况是锯齿状,涨上去会掉下来;如果每次操作后谷底都在抬高,基本可以判定泄漏。
更快的办法是手动触发 GC:按 `Ctrl+Shift+P`(Mac 是 `Cmd+Shift+P`)打开命令面板,输入 `Collect garbage` 回车,回落到 Memory 面板看 Heap Snapshot 上方的堆大小数字。反复操作同一功能 → 手动 GC → 如果堆大小每次都比上一次高几 MB,就是泄漏。
注意:不要只看一次数据就下结论。至少要循环「操作 → GC → 记录」三轮,单次波动可能是缓存预热,不是泄漏。
第二步:拍三张堆快照做对比
切到 Memory 面板,选中 Heap snapshot,点 Take snapshot。按下面节奏来:
- 页面加载稳定后,GC 一次,拍第 1 张(基线)
- 执行可疑操作(比如反复打开关闭弹窗、切换路由)10 次
- GC 一次,拍第 2 张
- 再执行同样操作 10 次,GC,拍第 3 张
重点是第 1 张和第 3 张对比——两次操作的间隔相同,如果某类对象数量在第 2、3 张之间还在涨,它就是泄漏源。
第三步:从 Summary 切到 Comparison 看增量
快照默认是 Summary 视图,看不出变化。把上方下拉框从 `Summary` 改成 `Comparison`,右侧选基准快照(第 1 张),然后按 `# Delta` 或 `Size Delta` 排序。
`(closure)`、`(array)`、`Detached HTMLDivElement`、`system / Context` 这几类如果 Delta 是正数且很大,就是嫌疑对象。
想直接找脱离文档的 DOM:
- 在快照顶部的 Filter 框输入 `Detached`,回车
- 或者在 Class filter 里输入 `Detached`
注意:`Detached` 节点不是「已经被回收」,恰恰相反——它已经被移出 DOM 树,但 JS 里还有变量引用着它,所以 GC 收不掉。这是最常见的泄漏形态。
第四步:用 Retainers 查引用链,找到谁在抓着它
在快照列表里点中一个可疑对象,下方会展开 Retainers(引用者) 面板。这一栏是从底往上看的一条链:谁引用了它 → 谁引用了那个谁 → …… → 一直到 GC Root。
GC Root 通常长这样:
- `Window / global` —— 挂在全局变量上,最常见
- `detached` —— 另一个脱离节点间接引用
- `EventListener` / `setInterval` 的回调 —— 定时器或监听没清除
- `Promise` —— 未 settle 的链持有闭包
顺着这条链找到你自己的业务代码那一层,就能锁定文件与变量名。点击右侧的链接可以直接跳到 Sources 面板对应位置打上断点。
第五步:用分配时间线抓「谁在持续分配」
如果快照太大不好读,用 Allocation instrumentation on timeline(分配时间线):
- Memory 面板选中 Allocation instrumentation on timeline
- 点录制,重复操作
- 停止后,蓝色竖条代表新分配且未被回收的内存
- 框选一段蓝条,下方列表就是这段时间分配的对象,展开看调用栈
这个方式最适合「一边操作一边涨」的场景,比快照对比快得多。
第六步:对照七种高频泄漏模式改代码
定位到位置后,多半逃不出这几种:
// 1. 全局变量意外累积
window.cache = window.cache || [];
window.cache.push(bigData); // 只增不减
// 2. 定时器没清
const t = setInterval(poll, 1000); // 组件卸载时忘了 clearInterval(t)
// 3. 事件监听没解绑
window.addEventListener('resize', handler); // 卸载时没 removeEventListener
// 4. 闭包持有大对象
function init() {
const huge = new Array(1e6).fill(0);
document.getElementById('btn').onclick = () => console.log(huge.length); // huge 被永久持有
}
// 5. 缓存 Map/Set 无上限
const map = new Map();
map.set(key, value); // 从不 delete
// 6. 游离 DOM 被引用
let el = document.getElementById('panel');
el.remove(); // DOM 移除,但变量 el 还在,节点无法回收
// 7. 未取消的订阅/请求回调
observer.observe(el); // 卸载时没 observer.disconnect()
修复的通用套路:谁注册谁负责注销。定时器、监听、观察者、订阅、请求回调,都在组件卸载钩子(如 React 的 `useEffect` 返回函数、Vue 的 `onUnmounted`)里清掉;缓存类结构加 LRU 上限或设 `WeakMap`/`WeakSet`。
注意:`WeakMap` 的 key 必须是对象,且只有 key 是弱引用,value 若被别处强引用照样泄漏。
第七步:改完再验证一遍
重新走一遍第二步的三快照流程。判断标准很简单:第 2 张和第 3 张的 Delta 接近 0,或者手动 GC 后堆大小能回到基线附近。没回落就说明还有第二处引用链没断。
小结
- 先用 Performance 的 Memory 曲线判断真假泄漏,`Collect garbage` 是筛子
- 三快照 + Comparison 视图看 `# Delta`,找正增长的对象类
- Filter 输入 `Detached` 快速揪出游离 DOM
- Retainers 面板顺着引用链往上找 GC Root,定位到自己的代码
- 分配时间线适合「边操作边涨」的持续型泄漏
- 修复口诀:谁注册谁注销,缓存必须有上限