页面卡了,别急着说内存泄漏

0 阅读7分钟

你的页面越来越慢,打开任务管理器一看——内存占了 800 MB。同事扫了一眼说:"内存泄漏。" 你心想:嗯,大概是吧。然后开始漫无目的地翻代码。

先别急。页面卡顿可能根本不是泄漏。

Chrome 官方把内存问题分成了三类:泄漏、膨胀、频繁 GC。这三种"病"的症状很像,但病因完全不同,治法也截然不同。搞混了,就是头痛医脚。

一、先分诊:你的页面是哪种"病"

打个比方。把内存问题想象成你去看医生:

症状病名本质
刚开始流畅,越用越卡内存泄漏对象创建了但没释放,垃圾越积越多
从打开开始一直卡内存膨胀页面用的资源超出设备承受力
间歇性卡顿,一抖一抖的频繁 GC短时间大量创建/销毁对象,GC 疲于奔命

这三种病,你至少得先分清是哪种,才能选对"科室"。

泄漏是慢性病——刚开始没感觉,用半小时后症状才明显。膨胀是先天体弱——一出生就不行。频繁 GC 是过敏体质——看着没事,但动不动就发作。

分诊的起点很简单:按 Shift + Esc,打开 Chrome 任务管理器。

Chrome 任务管理器显示内存和 JavaScript 内存两列

Chrome 任务管理器显示内存和 JavaScript 内存两列

重点看两列:

• 内存占用(Memory) :操作系统层面的内存,DOM 节点存在这里。如果这个数一直涨,说明在不断创建 DOM 节点。

• JavaScript 内存:关注括号里那个"实时"数字。如果它一直涨,说明 JS 堆里的活对象越来越多。

如果数字单调递增——大概率是泄漏。如果数字很高但稳定——可能是膨胀。如果数字反复锯齿状跳动——频繁 GC。

二、拍片子:用 Performance 看内存走势

任务管理器只是"挂号",真正要确诊,得拍个片子。

打开 DevTools → Performance 面板,勾选 Memory 复选框,开始录制。做一组典型的用户操作,录制 30-60 秒。录制前后各点一次「垃圾桶按钮」强制 GC——这一步很关键,它排除了"恰好没触发回收"的干扰。

Performance 面板的内存趋势图,展示 JS 堆、文档数、DOM 节点数的变化曲线

Performance 面板的内存趋势图,展示 JS 堆、文档数、DOM 节点数的变化曲线

读图的核心判据:

• JS Heap 曲线在强制 GC 后仍然比起始点高 → 有泄漏

• JS Heap 曲线一直很高但平坦 → 膨胀

• JS Heap 曲线频繁剧烈波动 → 频繁 GC

Performance 不告诉你"谁"泄漏了,但它告诉你"有没有"泄漏。

这就像医院拍的 X 光:它能看到骨头断了,但定位具体哪根神经受压,得做 CT 或核磁。

三、精确定位:四把手术刀

确认了"病种"之后,接下来用四个工具精确定位。

手术刀 1:Heap Snapshot — 给活对象拍张全家福

Memory 面板 → 选 Heap Snapshot → Take snapshot。

它的核心价值不是看"分配了什么",而是看 "什么东西还活着"

一个经典场景:搜索 Detached,找到所有从 DOM 树上摘下来、但仍然被 JS 引用着的节点。

在 Heap Snapshot 中搜索 Detached,筛选出所有已分离但未回收的 DOM 节点

在 Heap Snapshot 中搜索 Detached,筛选出所有已分离但未回收的 DOM 节点

点开分离节点,在 Retainers 面板能看到谁在引住它。比如一个全局变量 detachedTree 指向了一棵已经从页面移除的 DOM 子树——这就是病根。

React 中最常见的分离 DOM 场景:

// ❌ 组件卸载后,ref 仍然指向已移除的 DOM
const chartRef = useRef(null);

useEffect(() => {
  const chart = new HeavyChart(chartRef.current);
  // 忘了 return 清理函数
  // chart 内部持有 DOM 引用 → 分离但不释放
}, []);
// ✅ 正确做法:清理函数中销毁实例
useEffect(() => {
  const chart = new HeavyChart(chartRef.current);
  return () => chart.destroy(); // 断开引用链
}, []);

Heap Snapshot 回答的问题是:谁还活着?是谁把它留住了?

手术刀 2:Allocation Timeline — 看每一刻分配了什么

Memory 面板 → 选 Allocation instrumentation on timeline → Record。

它在时间轴上画出蓝色竖条——每根竖条代表那一时刻的新分配。录制完后,你可以框选某个时间区间,只看那段时间内新创建了哪些对象。

Allocation Timeline 上的蓝色竖条标记了内存分配时刻

Allocation Timeline 上的蓝色竖条标记了内存分配时刻

这个工具特别适合排查操作触发的泄漏:比如你怀疑每次切换路由都在泄漏,就录制切换前后那 10 秒,看蓝条分配了什么,这些对象后来有没有被回收。

手术刀 3:Allocation Sampling — 按函数查分配

Memory 面板 → 选 Allocation sampling → Start。

它的输出是一棵调用树,告诉你哪个函数分配了最多内存

Allocation Sampling 的调用树视图,按内存分配量排序

Allocation Sampling 的调用树视图,按内存分配量排序

如果你已经知道有泄漏,但不知道是哪段代码造成的,这个工具最高效。它的开销比 Allocation Timeline 小得多,适合长时间录制。

Timeline 看"什么时候分配的",Sampling 看"谁分配的"。两者互补。

手术刀 4:Detached Elements — 专查分离节点

Memory 面板 → 选 Detached elements → Get detached elements。

Detached Elements 面板显示被 JS 引用保留的已分离 HTML 元素

Detached Elements 面板显示被 JS 引用保留的已分离 HTML 元素

这个工具更直接——它不需要你在 Heap Snapshot 里搜 Detached,而是直接列出所有因 JS 引用而无法回收的已分离元素,连 HTML 标签名都给你标好了。

四、四步排查路径:从报案到结案

把上面的工具串起来,就是一条完整的侦查链:

步骤工具解决什么问题
1. 发现异常任务管理器内存是不是在异常增长?
2. 确认病种Performance 时间线是泄漏、膨胀还是频繁 GC?
3. 锁定嫌犯Heap Snapshot / Detached Elements哪些对象没被释放?谁在引住它?
4. 追踪源头Allocation Timeline / Sampling什么操作、哪个函数在制造垃圾?

这条路径的核心逻辑是:先确诊,再开刀。不要跳步。

很多人一遇到内存问题就直接打 Heap Snapshot,然后被几万行对象列表淹没。那是因为跳过了前两步——你连是不是泄漏都不确定,怎么知道该找什么?

五、React / Canvas 应用的四个高频雷区

如果你在做 React 复杂页面、Canvas 编辑器、可视化仪表盘,以下四种泄漏模式要格外注意:

1. 事件监听器忘了卸

// ❌
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

// ✅
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

2. 定时器忘了清

// ❌
useEffect(() => {
  setInterval(() => fetchData(), 5000);
}, []);

// ✅
useEffect(() => {
  const id = setInterval(() => fetchData(), 5000);
  return () => clearInterval(id);
}, []);

3. 异步请求没取消

// ✅ 用 AbortController 在组件卸载时取消未完成的请求
useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, []);

4. 闭包捕获了大对象

// ❌ 闭包捕获了整个 items 数组,即使组件已卸载
useEffect(() => {
  const items = generateHugeList(); // 10MB 数据
  const handler = () => console.log(items.length);
  window.addEventListener('scroll', handler);
  // 忘了 return 清理 → items 永远不释放
}, []);

每个 useEffect 都应该问自己一个问题:return 里该清理什么?

如果你养成这个习惯,大部分 React 内存泄漏根本不会发生。

如果你只想带走一句话

我建议记这个:页面卡了不等于内存泄漏——先分诊、再拍片、最后才开刀。

内存问题和看病一样:最怕的不是病重,而是误诊。把膨胀当泄漏治,你会在代码里加一堆无意义的 cleanup;把频繁 GC 当泄漏治,你会去堵一个根本不存在的"洞"。

先用任务管理器看趋势,再用 Performance 确认病种,最后用 Heap Snapshot 或 Allocation Sampling 定位病灶。这条路径走下来,大部分内存问题都能在 30 分钟内从"页面好卡"变成"找到了,是这行代码"。


参考原文:Kayce Basques — Fix memory problems · Chrome for Developers

qrcode_for_gh_6a9e7f3719d6_344.jpg