页面越来越卡?给内存拍张"CT"

0 阅读6分钟

你有没有遇到过这种情况:页面刚打开丝滑流畅,用了半小时后开始卡顿,切个 Tab 回来直接白屏?

多数人第一反应是"渲染太慢",然后一头扎进 Performance 面板找掉帧。但很多时候,真正的凶手不是渲染,而是内存里堆满了不该活着的对象

Heap Snapshot(堆快照)就是给内存拍一张 CT。它不是拿来看瞬时内存抖动的,而是用来回答三个关键问题:哪些对象还活着、为什么还活着、是谁把它们留住了

今天这篇,带你学会从三个视角看内存问题,把"越来越卡"拆解成可以验证的引用链问题。

一、先搞清两个数字

打开 Chrome DevTools → Memory 面板 → 选择 Heap Snapshot → 点击 Take Snapshot,你会看到一个 Summary 视图。

Summary 视图:按构造函数分组的对象列表,显示 Shallow Size 和 Retained Size

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 视图:两张快照之间的对象增减对比

Compare 视图:两张快照之间的对象增减对比

操作流程很简单:

页面初始状态,拍第一张快照

执行你怀疑导致泄漏的操作(比如打开弹窗、切换路由)

执行反向操作(关闭弹窗、切回路由),重复 2-3 次

拍第二张快照,切换 Compare 视图

如果那些理论上应该被销毁的对象在 Delta 列显示正增长——恭喜,你找到泄漏点了。

先拍片,再对比,有增量才深入。  这比盲目翻代码高效十倍。

3. Retainers:病因追溯

选中一个可疑对象,底部的 Retainers 面板会告诉你"谁在引用它"。

Retainers 面板:展示引用链,解释为什么对象无法被 GC 回收

Retainers 面板:展示引用链,解释为什么对象无法被 GC 回收

这就是刑侦里的证据链——GC 不是不想回收,而是有人在"保护"它。顺着引用链往上追,直到找到那个不该存在的引用,就是你的修复目标。

一个实用技巧:右键点击某个 retainer,选择"忽略此保留器"。DevTools 会重新计算,看还有没有其他路径保留了目标对象。这相当于排除法诊断——先屏蔽一个嫌疑人,看"病症"还在不在

三、最常见的泄漏凶手:Detached DOM

DOM 节点从页面树上摘掉了,但 JavaScript 还在引用它。这个节点以及它的整棵子树就变成了 "Detached DOM"——从页面上看不见,但在内存里占着坑。

Detached DOM 示意图:移除的节点通过 JS 引用被保留在内存中

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 视图:对象的层级引用结构全景

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 编辑器、长生命周期可视化面板时,排查内存问题的效率会提升一个量级。


参考来源:Record heap snapshots — Chrome DevTools

qrcode_for_gh_6a9e7f3719d6_344.jpg