内存泄漏是单页应用(SPA)最隐蔽的性能杀手。它不会立即导致崩溃,而是像慢性毒药——页面越来越慢,响应越来越迟钝,最终浏览器标签页占用数 GB 内存后崩溃。Memory 面板就是用来诊断和治愈这种"慢性病"的。
关键概念:JavaScript 的垃圾回收(GC)基于可达性。从根(Root)对象出发,通过引用链可达的任何对象都不会被回收。
| 模式 | 示例 | 影响 |
|---|---|---|
| 未清理的事件监听器 | 移除 DOM 元素但未 removeEventListener | 元素无法被 GC |
| 闭包保留大对象 | 闭包意外引用了整个外部作用域 | 大对象长期存活 |
| 全局变量/属性 | window.dataCache = hugeArray | 永不释放 |
| Detached DOM | 移除元素但 JS 仍持有其引用 | 不可见的 DOM 占用内存 |
| 定时器未清理 | setInterval 但未 clearInterval | 回调及其闭包不释放 |
| Promise 未 resolve/reject | 链式 Promise 中产生了悬挂引用 | 整个 Promise 链保留 |
| WebSocket 未关闭 | 页面切换后连接未 close | 连接对象常驻 |
| 工具 | 作用 | 使用场景 |
|---|---|---|
| Heap Snapshot | 快照式分析:拍两张快照对比 | 定位具体泄漏的对象类型 |
| Allocation instrumentation on timeline | 时间线分配:记录每帧的分配 | 追踪分配发生的时间点 |
| Allocation sampling | 采样分析:低开销长时间记录 | 初步探测是否存在泄漏 |
Heap snapshotTake snapshot 拍摄初始快照(Snapshot 1)Take snapshot 拍摄对比快照(Snapshot 2)| 视图 | 说明 | 最佳场景 |
|---|---|---|
| Summary | 按构造函数分组,显示对象计数和大小 | 定位泄漏的类型 |
| Comparison | 两个快照之间的差异对比 | 🔑 寻找泄漏对象的核心视图 |
| Containment | 按引用层级组织对象树 | 理解对象的引用链 |
| Statistics | 按数据类型(Array, String, Code 等)饼图 | 了解内存整体分布 |
这是定位内存泄漏最重要的视图:
Comparison 视图Perspective 下拉中选择 Snapshot 1 作为基准# Delta(数量变化)排序关注以下信号:
| 指标 | 含义 |
|---|---|
| Shallow Size | 对象自身占用的内存(不含引用的其他对象) |
| Retained Size | 对象自身 + 其独占引用的所有对象的内存总和 |
| Distance | 从 GC Root 到该对象的最短引用链长度 |
| # New | Comparison 视图中新创建的对象数量 |
| # Deleted | Comparison 视图中被删除的对象数量 |
| # Delta | # New - # Deleted(净增量,正值 = 泄漏信号) |
Detached DOM 是已从 DOM 树移除但由于 JavaScript 仍持有引用而无法被 GC 的 DOM 元素。
在 Comparison 视图中:
DetachedRetainers 面板Retainers 面板显示了谁在持有这个对象的引用:
💡 Retainers 面板是追踪引用链的核心工具:从 Detached 对象开始,沿着 Retainers 逐层向上,就能找到阻止 GC 的根源代码。
场景:用户反映在 Dashboard 和 Settings 页面之间反复切换后,页面越来越卡。
步骤:
预期分析:
进一步定位:
与 Heap Snapshot 的"拍快照对比"不同,Allocation instrumentation 会持续记录每个对象的分配时机,生成一条时间线。
Allocation instrumentation on timelineStartStop关键信号:
通过 Command Menu → Show Performance Monitor 可以打开实时监控面板。
关注以下指标:
💡 Performance Monitor 是发现泄漏的第一道防线。在开发过程中保持它打开,就能在泄漏早期发现异常。
除了框架特定的场景,以下四类通用内存泄漏在任意项目中都很常见:
function createLeak() {
const largeData = new Array(1000000).fill('x');
return function() {
// 即使不使用 largeData,它也不会被 GC
console.log('leak');
};
}
const leak = createLeak();排查:在 Heap Snapshot 中搜索 Array,查看 Retainers,会发现闭包引用了 largeData。
function Component() {
window.addEventListener('resize', handler);
// 组件卸载时未移除监听器 → handler 引用的对象无法被 GC
}排查:在 Heap Snapshot 中搜索 EventListeners,查看哪些监听器没有被移除。
const list = document.getElementById('list');
const items = list.children;
list.innerHTML = '';
// items 仍然引用了已移除的 DOM 节点排查:在 Heap Snapshot 中搜索 Detached,找到 Detached DOM 节点,查看 Retainers 找到保持引用的 JS 代码。
function processData(data) {
leakedData = data; // 没有 var/let/const,变成了全局变量
}排查:在 Heap Snapshot 中查看 Global 对象下的属性。
在 Heap Snapshot 的搜索框中输入类名,如 Array、Object、HTMLDivElement,可以快速过滤到特定类型的对象。
在 Retainers 面板中,可以看到从 GC Roots 到目标对象的完整引用路径:
Window → Global → leakedData → Array(1000000)这条路径就是对象无法被 GC 回收的原因——有从 GC Roots 出发可以到达该对象的引用链。
useEffect(() => { const timer = setInterval(...); /* 缺少 return () => clearInterval(timer) */ }, [])watch() 返回的 unwatch 未在组件卸载时调用$on() 注册的事件在组件卸载时未 $off()HTMLDivElement、HTMLSpanElement 等 DOM 构造函数的 # DeltaEventListener 对象的数量变化