看完这篇,你能分清「内存正常增长」和「真的泄漏」,并按步骤揪出泄漏的代码位置。
JavaScript 的内存泄漏很少是某一行代码写错,多半是某个引用没被解除。所以排查的核心永远是同一个动作:找到那条「本该断掉却还连着」的引用链。下面按顺序来。
第一步:先确认它到底是不是泄漏
内存涨不一定是泄漏。页面加载后堆从 20MB 涨到 60MB 很正常,关键是反复做同一个操作后,强制 GC 能不能回落到基线。
打开 Chrome DevTools:
F12 → 右上角 ⋮ → More tools → Performance monitor(性能监视器)
- 勾选
JS heap size、DOM Nodes、JS event listeners
- 在页面上重复操作十几次(比如反复打开/关闭弹窗、切换列表页)
判断标准:操作结束后点一下 Collect garbage(垃圾桶图标),如果 JS heap 和 DOM Nodes 都回不到操作前的数值,且每轮都在抬升——这就是泄漏,继续往下。
注意:DOM Nodes 只增不减几乎百分百是「游离 DOM」,比堆内存更好确认,优先看这个指标。
第二步:对照五类高频原因自查
排查之前先看一眼代码,多半能直接命中:
1. 意外的全局变量:函数里 foo = 123 忘了 let,或 this 在松散函数里指向 window,数据就永久挂在全局。ESLint 的 no-undef、no-implicit-globals 能拦掉大半。
2. 定时器没人关:setInterval 里引用了组件状态或 DOM,页面切走了定时器还在跑。组件销毁处必须 clearInterval(id),别只写 setInterval。
3. 事件监听重复绑定:addEventListener 加在 window / document 上,组件卸载时不 removeEventListener。匿名函数无法移除,所以绑定时要把函数存成变量,或用 AbortController 统一解绑:
const ac = new AbortController();
window.addEventListener('resize', onResize, { signal: ac.signal });
// 销毁时
ac.abort();
4. 游离 DOM:节点已经从页面上删了,但 JS 数组/Map/闭包里还存着它的引用。典型场景:const cache = [] 里 push 了 DOM 节点、Map 用 DOM 当 key。用 DOM 当 key 时换成 WeakMap,元素被回收时条目自动消失。
5. 订阅没取消:WebSocket onmessage、IntersectionObserver、ResizeObserver、状态库 subscribe、EventBus 的 on。这类泄漏的特征是回调执行次数越来越多,可以在回调里 console.count() 验证。
另外两类容易被忽略:无上限缓存(Map 当缓存用但从不清理,加个上限或定期清)、待处理的 Promise / fetch(请求发出后组件已卸载,回调里还操作 DOM——用 AbortController 取消 fetch)。
第三步:用堆快照对比,定位到具体对象
这一步是重点,参数别搞错:
Memory 面板 → 选中 Heap snapshot → Take snapshot
- 做一次可疑操作,然后回到 DevTools 点垃圾桶 强制 GC(不 GC 会看到一堆该回收没回收的假象)
- 再拍一张快照
- 重复第 2、3 步,凑够 3 张快照
- 在快照列表里选 最后一张,上方视图切换为
Comparison,对比基准选你操作前那张
然后按这两列排序:
# New:新增对象数量
Size Delta:新增占用字节
把 Constructor 列里数量暴增的项展开,通常是某个业务对象、数组或 Detached HTMLDivElement。
注意:看 Size Delta 而不是只看 # New。字符串、数组这类大量小对象,数量多但增量小;真正拖垮页面的是少数几个大对象。
找游离 DOM 更直接:在快照顶部的 Filter 框输入 Detached,会列出所有已脱离文档但被引用的节点,展开任意一个,下方 Retainers(保留器)面板会显示谁在引用它——这一条引用链就是元凶,逐层往上翻能看到具体是哪个闭包、哪个数组下标。
第四步:用时间轴抓「谁在分配」
堆快照告诉你「谁没被回收」,Allocation instrumentation on timeline 告诉你「谁分配的」:
Memory 面板 → 选 Allocation instrumentation on timeline → Start → 操作 → Stop。蓝色竖条是仍存活的对象,灰色是已回收的,点蓝色竖条能看到分配时的完整调用栈。
注意:这个模式会记录每次分配,本身有性能开销,只在本地复现时开,别在线上长时间开着。
想更省事可以用 Performance 面板录一段,勾上 Memory,看 JS Heap 曲线的走势——曲线呈阶梯状只上不下,就是泄漏的典型形态。
第五步:Node 与线上环境
服务端或构建脚本里:
node --inspect app.js
浏览器打开 chrome://inspect 连上去,Memory 面板用法和前端一致。快速看趋势可以用:
setInterval(() => console.log(process.memoryUsage()), 10000);
关注 heapUsed,持续上涨且 GC 后不回落即异常。要导出快照就装 heapdump,调 heapdump.writeSnapshot() 生成 .heapsnapshot 文件,再拖回 DevTools 打开分析。
小结
- 判断标准只有一个:反复操作 + 强制 GC 后,堆和 DOM 节点数能不能回到基线
- 五类高频原因:意外全局变量、定时器未清、监听未解绑、游离 DOM、订阅未取消;缓存无上限和未取消的请求紧随其后
- 排查主武器是
Heap snapshot + Comparison 视图,按 Size Delta 排序;找 DOM 就用 Detached 过滤 + Retainers 引用链
- 排查时务必先 GC 再拍快照,且至少对比三张,单张快照说明不了问题
- 修复优先用结构手段:
WeakMap / WeakSet 存 DOM,AbortController 统一解绑,缓存加上限