前端内存泄漏是单页应用(SPA)最常遇到的性能顽疾之一。页面开久了标签页越来越卡、任务管理器里 JS 内存只涨不跌、切换路由后旧页面的对象迟迟不被回收——这些几乎都是内存泄漏的典型信号。本文以 Chrome DevTools 为主力工具,配合几段可复现的示例代码,带你用 30 分钟走完”发现问题 → 定位根因 → 修复验证”的完整闭环,并附上一份可直接落地的排查清单。
一、先搞清楚:什么是前端内存泄漏
JavaScript 运行在 V8 等带垃圾回收(GC)的引擎上。GC 通过”可达性”判断对象是否该被回收:从根(window、当前执行栈、全局变量)出发,凡是还能被引用链触碰到的对象,就保留;触碰不到的,下一次回收时释放。所谓前端内存泄漏,就是那些”业务上已经不再需要、却因为某条引用链依然存在”而无法被回收的对象,随着时间不断堆积,最终拖垮页面。
它和后端内存溢出不同:前端极少直接 OOM,更多表现为”越用越慢”。先建立一份判断基线,能帮你快速区分偶发波动与真实泄漏。
| 观察指标 | 健康表现 | 泄漏表现 |
|---|---|---|
| JS 堆内存 | 操作后 GC 能回落 | 只涨不跌,锯齿持续上行 |
| DOM 节点数 | 路由切换后回落 | 持续累积不释放 |
| 事件监听器数 | 总体稳定 | 随操作线性增长 |
| 标签页内存 | 长时间稳定 | 越用越大,最终卡死 |
二、六类最常见的泄漏场景
2.1 意外创建的全局变量
忘记写 var/let/const,变量会悄悄挂到 window 上,成为永远不会被回收的全局引用。
function leak() {
// 漏写声明,foo 变成 window.foo 全局变量
foo = new Array(1000000).fill('*');
}
leak();
// window.foo 一直存活,1M 个字符串无法释放
2.2 遗忘的定时器与回调
setInterval 的回调若持有外部对象,而定时器从未被 clearInterval,回调闭包会让整棵引用树无法回收。
function startTimer() {
setInterval(() => {
// 闭包持有了外部的大对象 element
console.log(element.innerHTML.length);
}, 1000);
}
// 忘记 clearInterval,定时器与 element 永远不释放
2.3 脱离文档却仍被引用的 DOM 节点
已经从页面移除(detached)的 DOM 节点,只要还被某个 JS 数组或变量引用,就既占内存又占 DOM 计数。
let detachedNodes = [];
function createLeak() {
const div = document.createElement('div');
div.innerHTML = '<p>large content</p>';
div.remove(); // 已从文档移除
detachedNodes.push(div); // 但仍被数组引用,无法回收
}
2.4 闭包持有大对象
function createHeavyClosure() {
const bigData = new Array(1e6).fill('x'); // 1M 字符串
return function () {
// 外部函数已返回,但 bigData 仍被返回的函数持有
return bigData.length;
};
}
const getLen = createHeavyClosure();
// getLen 一直存活,bigData 无法释放
2.5 事件监听器未解绑
const btn = document.getElementById('btn');
btn.addEventListener('click', onClick);
// 组件销毁后忘记 removeEventListener
// 若 btn 本身是 detached DOM,泄漏会更严重
// 正确做法:btn.removeEventListener('click', onClick);
2.6 缓存无限增长
用普通 Map 做缓存且不设上限,key 永远不会被 GC;改用 WeakMap,当 key 不可达时即可被回收。
const cache = new Map(); // 普通 Map:key 永远不被 GC
function addUser(u) { cache.set(u.id, u); }
const weakCache = new WeakMap(); // WeakMap:key 不可达时可回收
function addWeak(u) { weakCache.set(u, u.profile); }
三、用 Chrome DevTools 定位泄漏
3.1 Performance Monitor 看趋势
打开 DevTools → 右上角 ⋮ → More tools → Performance Monitor。重点看三条曲线:JS Heap Size、DOM Nodes、JS event listeners。反复执行”打开页面 → 操作 → 关闭/返回”的动作序列,如果每次循环结束后这三条曲线都不回落,而是台阶式上移,基本可以判定存在泄漏。这一步只做”定性”,不找具体对象。
3.2 Memory 面板:堆快照对比
切到 Memory 面板,选 “Heap snapshot”:先在初始状态拍一张,执行可疑操作若干次后再拍一张,然后在第二张快照里把对比基准选为第一张,按 Retained Size 降序排列,泄漏对象往往就排在前面。配合 “Detached” 过滤,能直接揪出那些已脱离文档却被 JS 持有的 DOM 节点。想深入理解任务调度与卡顿的关系,可回顾 浏览器事件循环 一文。
3.3 Performance 录制分配时间线
选 “Allocation instrumentation on timeline”,录制期间看到的蓝色竖条代表一次内存分配,红色竖条代表这部分内存在录制结束时仍未释放——点开红色条就能看到分配发生在哪一行代码。三种工具分工明确:
| 工具 | 适用场景 | 关键动作 |
|---|---|---|
| Performance Monitor | 快速判断是否有泄漏趋势 | 看 JS Heap / DOM Nodes 曲线 |
| Memory 快照对比 | 定位”谁”在泄漏 | 按 Retained Size 排序 |
| Performance 时间线 | 定位”哪里”分配 | 看红色未释放竖条 |
四、实战:复现并修复一个泄漏
下面是一个会泄漏的 React 组件:它在挂载时启动了定时器并监听了 window 的 resize,但卸载时什么都没清理。用户反复进出这个路由,定时器与监听器越积越多,组件实例也无法被回收。
// LeakyChart.jsx —— 一个会泄漏的图表组件
import { useEffect, useState } from 'react';
export default function LeakyChart() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
window.addEventListener('resize', () => setCount(c => c + 1));
// 错误:卸载时既没 clearInterval,也没移除监听
}, []);
return <div>count: {count}</div>;
}
修复方式极其简单——在 useEffect 里返回一个清理函数,卸载时反向操作即可:
// FixedChart.jsx
import { useEffect, useState } from 'react';
export default function FixedChart() {
const [count, setCount] = useState(0);
useEffect(() => {
const onResize = () => setCount(c => c + 1);
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => { // 卸载清理
clearInterval(id);
window.removeEventListener('resize', onResize);
};
}, []);
return <div>count: {count}</div>;
}
五、在框架中规避泄漏
现代框架都提供了”卸载钩子”,关键是把清理写进对应的生命周期。React 用 useEffect 的返回函数(如上),Vue 3 用 onUnmounted:
// Vue 3 组合式 API
import { onMounted, onUnmounted, ref } from 'vue';
export default {
setup() {
const timer = ref(null);
onMounted(() => {
timer.value = setInterval(() => console.log('tick'), 1000);
});
onUnmounted(() => clearInterval(timer.value)); // 关键清理
}
};
涉及 React 性能优化 与 Vue 3 实战 的项目,建议在 Code Review 阶段就把”是否有对应清理”作为必查项。另外,把重计算移出主线程(见 Web Worker 实战)也能显著降低主线程压力,与内存治理相辅相成。
六、把内存回归检测搬进 CI
人工排查无法阻止回归。可以用 Puppeteer 在 GitHub Actions 里自动跑”打开 → 反复操作 → 关闭”的脚本,对比操作前后的堆快照差值,超过阈值就让流水线失败:
// memory-regression.test.js(Puppeteer + 堆快照差值)
import puppeteer from 'puppeteer';
test('no memory leak after navigation', async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000');
const before = await takeHeapSnapshot(page);
for (let i = 0; i < 20; i++) {
await page.click('#open-modal');
await page.click('#close-modal');
}
const after = await takeHeapSnapshot(page);
expect(after - before).toBeLessThan(5 * 1024 * 1024); // 允许 5MB 增长
await browser.close();
});
七、落地清单 Checklist
| 场景 | 必做清理 |
|---|---|
| 定时器 setInterval/setTimeout | clearInterval / clearTimeout |
| 事件监听 addEventListener | removeEventListener(同一引用) |
| 订阅 Observable / EventEmitter | unsubscribe / off |
| 第三方库实例 | 调用其 destroy / dispose |
| 大数组 / 缓存 | 用 WeakMap 或设上限 |
内存治理的精髓就是一句话:谁创建,谁销毁。把定时器、监听器、订阅、第三方实例的清理写进对应的卸载钩子,再用 DevTools 三件套做定期的”趋势—对象—代码”三级定位,前端内存泄漏就不再是玄学。把它纳入 前端性能优化 的常态化工作,用户体验会肉眼可见地变稳。




