你的页面越来越慢,打开任务管理器一看——内存占了 800 MB。同事扫了一眼说:"内存泄漏。" 你心想:嗯,大概是吧。然后开始漫无目的地翻代码。
先别急。页面卡顿可能根本不是泄漏。
Chrome 官方把内存问题分成了三类:泄漏、膨胀、频繁 GC。这三种"病"的症状很像,但病因完全不同,治法也截然不同。搞混了,就是头痛医脚。
一、先分诊:你的页面是哪种"病"
打个比方。把内存问题想象成你去看医生:
| 症状 | 病名 | 本质 |
|---|---|---|
| 刚开始流畅,越用越卡 | 内存泄漏 | 对象创建了但没释放,垃圾越积越多 |
| 从打开开始一直卡 | 内存膨胀 | 页面用的资源超出设备承受力 |
| 间歇性卡顿,一抖一抖的 | 频繁 GC | 短时间大量创建/销毁对象,GC 疲于奔命 |
这三种病,你至少得先分清是哪种,才能选对"科室"。
泄漏是慢性病——刚开始没感觉,用半小时后症状才明显。膨胀是先天体弱——一出生就不行。频繁 GC 是过敏体质——看着没事,但动不动就发作。
分诊的起点很简单:按 Shift + Esc,打开 Chrome 任务管理器。
Chrome 任务管理器显示内存和 JavaScript 内存两列
重点看两列:
• 内存占用(Memory) :操作系统层面的内存,DOM 节点存在这里。如果这个数一直涨,说明在不断创建 DOM 节点。
• JavaScript 内存:关注括号里那个"实时"数字。如果它一直涨,说明 JS 堆里的活对象越来越多。
如果数字单调递增——大概率是泄漏。如果数字很高但稳定——可能是膨胀。如果数字反复锯齿状跳动——频繁 GC。
二、拍片子:用 Performance 看内存走势
任务管理器只是"挂号",真正要确诊,得拍个片子。
打开 DevTools → Performance 面板,勾选 Memory 复选框,开始录制。做一组典型的用户操作,录制 30-60 秒。录制前后各点一次「垃圾桶按钮」强制 GC——这一步很关键,它排除了"恰好没触发回收"的干扰。
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 节点
点开分离节点,在 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 上的蓝色竖条标记了内存分配时刻
这个工具特别适合排查操作触发的泄漏:比如你怀疑每次切换路由都在泄漏,就录制切换前后那 10 秒,看蓝条分配了什么,这些对象后来有没有被回收。
手术刀 3:Allocation Sampling — 按函数查分配
Memory 面板 → 选 Allocation sampling → Start。
它的输出是一棵调用树,告诉你哪个函数分配了最多内存。
Allocation Sampling 的调用树视图,按内存分配量排序
如果你已经知道有泄漏,但不知道是哪段代码造成的,这个工具最高效。它的开销比 Allocation Timeline 小得多,适合长时间录制。
Timeline 看"什么时候分配的",Sampling 看"谁分配的"。两者互补。
手术刀 4:Detached Elements — 专查分离节点
Memory 面板 → 选 Detached elements → Get detached elements。
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