你有没有遇到过这种情况:页面刚打开丝滑流畅,用了半小时后开始卡顿,切个 Tab 回来直接白屏?
多数人第一反应是"渲染太慢",然后一头扎进 Performance 面板找掉帧。但很多时候,真正的凶手不是渲染,而是内存里堆满了不该活着的对象。
Heap Snapshot(堆快照)就是给内存拍一张 CT。它不是拿来看瞬时内存抖动的,而是用来回答三个关键问题:哪些对象还活着、为什么还活着、是谁把它们留住了。
今天这篇,带你学会从三个视角看内存问题,把"越来越卡"拆解成可以验证的引用链问题。
一、先搞清两个数字
打开 Chrome DevTools → Memory 面板 → 选择 Heap Snapshot → 点击 Take Snapshot,你会看到一个 Summary 视图。
Summary 视图:按构造函数分组的对象列表,显示 Shallow Size 和 Retained Size
这张表里最重要的两列:
| 概念 | 含义 | 类比 |
|---|---|---|
| Shallow Size | 对象自身直接占用的字节数 | 一个人的体重 |
| Retained Size | 删除该对象后能释放的总内存(自身 + 被它独占引用的所有对象) | 一个人离职后,整个团队能腾出多少工位 |
看 Shallow Size 是看个体,看 Retained Size 才是看影响面。
一个 128 字节的小对象,Retained Size 可能是 4MB——因为它引用了一棵没人要的 DOM 子树。这就是为什么你在 Summary 里一定要按 Retained Size 排序。
二、三张CT片:Summary、Compare、Retainers
堆快照给你提供了三个核心视角,对应三种不同的诊断思路。这很像医院做全面检查——不能只看一张片子就下结论。
1. Summary:化验单总览
Summary 按构造函数分组,告诉你"现在活着的都有谁"。你可以在这里快速发现异常:
• 某个构造函数的实例数量远超预期?
• (string) 或 (array) 的 Retained Size 异常大?
• 使用构造函数过滤器筛选"由已分离的节点保留的对象"?
这是起手式——先摸清现状。
2. Compare:复查对比
拍两张快照,切换到 Compare 视图。
Compare 视图:两张快照之间的对象增减对比
操作流程很简单:
页面初始状态,拍第一张快照
执行你怀疑导致泄漏的操作(比如打开弹窗、切换路由)
执行反向操作(关闭弹窗、切回路由),重复 2-3 次
拍第二张快照,切换 Compare 视图
如果那些理论上应该被销毁的对象在 Delta 列显示正增长——恭喜,你找到泄漏点了。
先拍片,再对比,有增量才深入。 这比盲目翻代码高效十倍。
3. Retainers:病因追溯
选中一个可疑对象,底部的 Retainers 面板会告诉你"谁在引用它"。
Retainers 面板:展示引用链,解释为什么对象无法被 GC 回收
这就是刑侦里的证据链——GC 不是不想回收,而是有人在"保护"它。顺着引用链往上追,直到找到那个不该存在的引用,就是你的修复目标。
一个实用技巧:右键点击某个 retainer,选择"忽略此保留器"。DevTools 会重新计算,看还有没有其他路径保留了目标对象。这相当于排除法诊断——先屏蔽一个嫌疑人,看"病症"还在不在。
三、最常见的泄漏凶手:Detached DOM
DOM 节点从页面树上摘掉了,但 JavaScript 还在引用它。这个节点以及它的整棵子树就变成了 "Detached DOM"——从页面上看不见,但在内存里占着坑。
Detached DOM 示意图:移除的节点通过 JS 引用被保留在内存中
看一个具体场景:
// ❌ 泄漏示例
let cachedNodes = [];
function showModal() {
const modal = document.createElement('div');
modal.innerHTML = '<p>一大坨内容...</p>';
document.body.appendChild(modal);
cachedNodes.push(modal); // 全局数组持有引用
}
function hideModal() {
const modal = document.querySelector('.modal');
modal.remove(); // DOM 移除了,但 cachedNodes 还引用着!
}
modal.remove() 只是把节点从 DOM 树上摘掉,但 cachedNodes 数组还死死抱着它。GC 一看:"有人还在用呢",不敢回收。
修复很简单——断开引用:
// ✅ 修复方案
function hideModal() {
const modal = document.querySelector('.modal');
modal.remove();
cachedNodes = cachedNodes.filter(n => n !== modal); // 释放引用
}
对象释放分两步:从 DOM 树上摘掉是第一步,从 JS 引用中断开才是关键的第二步。
四、React 中的隐形泄漏:闭包 + 副作用
在 React SPA 里,泄漏更隐蔽。组件卸载了,但副作用里的闭包还捏着旧状态。
三个最常见的模式:
模式一:WebSocket 关了但引用没断
// ❌ ws.close() 不够
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/stream');
ws.onmessage = (e) => setData(JSON.parse(e.data));
return () => ws.close(); // 关了连接,但 onmessage 的闭包还引用着 setData
}, []);
onmessage 的闭包捕获了 setData,形成引用链:事件处理器 → state setter → 组件 fiber → DOM 节点。仅仅 close() 不会断开这条链。
// ✅ 显式移除监听器
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/stream');
const handler = (e) => setData(JSON.parse(e.data));
ws.addEventListener('message', handler);
return () => {
ws.removeEventListener('message', handler);
ws.close();
};
}, []);
模式二:setInterval 没清理
// ❌ 每次依赖变化都新建 interval,旧的不会自动消失
useEffect(() => {
setInterval(() => fetchData().then(setData), 5000);
}, [connected]);
// ✅ 返回清理函数
useEffect(() => {
const id = setInterval(() => fetchData().then(setData), 5000);
return () => clearInterval(id);
}, [connected]);
模式三:闭包捕获过时状态
// ❌ ResizeObserver 的回调闭包锁死了旧 state
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (metrics.length > 0) recalcLayout(entries, metrics);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []); // metrics 被闭包捕获,永远是初始值
// ✅ 用 ref 代替直接引用 state
const metricsRef = useRef(metrics);
metricsRef.current = metrics;
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (metricsRef.current.length > 0) recalcLayout(entries, metricsRef.current);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
副作用建立的连接,清理时要把"连接"和"引用"都断干净。
五、Containment 视图:换个角度做鸟瞰
Containment 视图:对象的层级引用结构全景
如果 Summary 是"按科室分类的检查报告",Containment 就是"全身X光片"。它按对象的引用层级展示整个堆结构:
• DOMWindow:JS 全局对象入口
• GC roots:垃圾回收器的根节点
• Native objects:DOM 节点、CSS 规则等浏览器内部对象
当你在 Summary 里找不到突破口时,切到 Containment 视图从全局入手,沿着引用树往下钻,往往能发现意想不到的大对象藏在哪个闭包里。
六、实战排查清单
每次遇到"页面越来越卡",照着这个清单走:
先拍快照:操作前一张,操作后一张
Compare 找增量:按 Size Delta 排序,看哪些对象在增长
Summary 看大户:按 Retained Size 排序,找占内存最多的构造函数
Retainers 追链条:选中可疑对象,顺着引用链往上找"保护伞"
断开引用:定位到代码中的持有者,清理副作用或释放引用
每个 useEffect 都问自己四个问题:
• 创建了订阅/监听器/定时器?→ 清理函数里拆掉了吗?
• 清理逻辑是只 close 了还是也 removeEventListener 了?
• 闭包捕获了会变的 state?→ 该用 ref 吗?
• 有异步操作可能在卸载后 resolve?→ 用 AbortController 了吗?
如果你只想带走一句话,我建议记这个:
页面卡顿先别急着查渲染——给内存拍张 CT,按 Retained Size 排序,顺着 Retainers 追证据链,答案就在引用关系里。
你无法优化你无法观测的东西。而堆快照,就是让内存问题从"感觉越来越卡"变成"这个闭包引用了那棵 Detached DOM 子树"的关键工具。
学会它,你做 React 复杂页面、Canvas 编辑器、长生命周期可视化面板时,排查内存问题的效率会提升一个量级。