用 Chrome DevTools Memory 面板排查一次 SPA 内存泄漏

仓库那边有一块常年挂着监控大屏的电视,跑的是我们的实时订单看板页。这周值班收到反馈:早上上班时页面卡得没法看,数字半天跳一次,点刷新按钮没反应,只能整页刷新救回来——而且"每天早上都这样"。页面白天被人刷来刷去没事,一到没人动它的夜里就出问题,这个规律本身就把嫌疑指向了内存:一个不刷新的 SPA 等于一个跑了十几个小时的长进程,任何"每次多占一点、用完不还"的代码都会被时间放大成事故。

看板页是今年上半年做的,Vue 2.6 加 ECharts,轮询接口每十秒更新一次数据。代码评审时没人看出问题,实际上这类问题也基本不可能靠看代码看出来——只能靠工具。这篇把整个排查过程记下来,DevTools 的 Memory 面板我以前只是零星点开过,这次算是完整用了一遍。关于 V8 的栈堆划分和垃圾回收机制,去年补基础时写过一篇,这里直接用结论:JS 的内存靠可达性回收,泄漏的本质从来不是"忘了释放",而是"意外地还被引用着"。 排查的全部工作,就是找出那些意外的引用是谁。

第一步:先确认是不是内存,别急着开快照

页面卡的原因很多,先花十分钟确认方向,比一头扎进快照里划算。

最快的一眼是 Chrome 自带的任务管理器(Shift + Esc),它按标签页列出内存占用。我把看板页在测试环境开着,午饭前后各看一眼:从 180MB 涨到了 400 多 MB,两个小时翻了一倍多,而页面上的数据量并没有变化。这基本就实锤了泄漏,剩下的问题只是"漏在哪"。

顺带把"内存涨"和"页面卡"之间的因果链说清楚,因为它们不是一回事。堆越大,每次垃圾回收要扫描的对象越多,主线程被 GC 停顿占走的时间就越长;堆水位逼近上限时,V8 会越来越频繁地做全量回收,一次就是几百毫秒的整页冻结。看板页"数字半天跳一次"的体感,本质是主线程在 GC 和业务代码之间来回窒息。所以内存问题的早期症状往往不是崩溃,而是"越用越卡",等到崩溃报错(或者标签页直接变成"喔唷,崩溃啦")已经是晚期了。

再进一步是 Performance 面板。勾上 Memory 选项,录制一段典型的使用过程——对看板页来说就是干等着让轮询跑,我录了五分钟。看 JS Heap 那条曲线,正常应该是锯齿状:内存随着代码执行爬升,GC 一来掉下去,锯齿的谷底连线基本水平。而看板页的曲线是"上升的锯齿"——GC 照常在工作,每次也确实回收掉一些,但每个谷底都比上一个高。锯齿本身是健康的,锯齿的谷底连线持续抬升才是泄漏的形状,两者别搞混——第一次看这条曲线的同事很容易把正常的锯齿爬坡当成泄漏。

Performance 面板下方还有 Nodes(DOM 节点数)和 Listeners(事件监听数)两条曲线,这次它们提供了重要线索:Nodes 曲线也在阶梯式上涨,五分钟涨了小一万个节点,而页面上肉眼可见的 DOM 根本没变多。DOM 节点只增不减,指向一个经典嫌疑:Detached DOM——从文档树上摘下来了、却还被 JS 引用着回收不掉的节点。

大屏那台电视上不方便开 DevTools,我还在页面上临时加了一段自采样代码,把内存数据打进我们自己的埋点里,用来在真实环境验证问题和后续修复效果:

1// 仅 Chrome 有效:performance.memory 是非标准 API
2if (performance.memory) {
3  setInterval(() => {
4    track('heap_sample', {
5      used: Math.round(performance.memory.usedJSHeapSize / 1048576),
6      total: Math.round(performance.memory.totalJSHeapSize / 1048576),
7    });
8  }, 60000);
9}

performance.memory 只有 Chrome 实现,数值默认还是量化过的粗粒度值,做不了精确分析,但拉一条过夜的趋势线绰绰有余。从埋点看,线上那台电视的堆内存到早上已经涨过 1.2GB,和"每天早上都卡"的反馈完全对上。还有个环境因素要交代:那块大屏接的是个低配的盒子,物理内存只有 2GB,同样的泄漏速度,开发机上可能三天都攒不出症状,它一夜就到临界点——这也是"开发时没人发现"的原因之一,长驻页面的内存测试得按目标设备的下限来。

堆快照三连拍:Comparison 视图怎么读

方向确认了,进 Memory 面板拍堆快照。这里有个直接影响结论可信度的操作习惯,先记下来:拍快照前先点一下面板上的垃圾桶图标手动触发 GC(拍快照本身也会触发一次),保证快照里躺着的都是"GC 收不走"的对象,而不是"还没来得及收"的对象。另外要用无痕窗口排查,浏览器扩展注入的脚本会往堆里塞自己的对象,把快照搅浑。

还有个复现效率的问题。泄漏靠十秒一次的轮询慢慢攒,等它攒出可观测的差异太熬人,排查期间我把轮询间隔临时改成了五百毫秒——泄漏的形状不变,速度快二十倍,原来要等一小时的增长五分钟就看得清清楚楚。测试环境专门留了个 ?debug_interval=500 的开关:

1const query = new URLSearchParams(location.search);
2const POLL_INTERVAL = Number(query.get('debug_interval')) || 10000;

能人为加速的泄漏才好排查,这个开关后来在复查阶段也一直在用。

单独一张快照的信息量有限——几十 MB 的堆里躺着几十万个对象,谁是泄漏的谁是正常的根本分不出来。有用的是对比,我的做法是三连拍:

第一拍,页面加载完成、数据稳定后拍一张基线。第二拍,让嫌疑操作发生几轮——对看板页就是干等一分钟让轮询跑六次——再拍一张。第三拍,再等一分钟拍第三张。三张的意义在于交叉验证:只对比两张,新增对象里混着大量"恰好在场"的临时对象;有了第三张,如果某类对象在 1→2 和 2→3 两段里都在稳定增长,它基本就是泄漏本漏,偶然在场的东西不会两段都涨。

对比的入口在快照列表上方的下拉框,从 Summary 切到 Comparison,再选好对比的基准快照。表格里几列的含义值得记牢:#New 是这段时间新分配的对象数,#Deleted 是被回收的,#Delta 是净增长,Alloc. Size / Freed Size / Size Delta 是对应的字节数。排查时直接按 Size Delta 倒序,泄漏大户一般就浮在最上面几行。

读快照还有两个概念不先分清就会走弯路:Shallow Size 和 Retained Size。Shallow 是对象自己那点字节数,一个 div 的 Shallow Size 小得可怜;Retained 是"如果这个对象被回收,能连带释放多少"——它是整条独占引用链的总账。找泄漏要按 Retained Size 排序看,一个 Shallow 只有几十字节的 Map,Retained 可能是几十 MB,因为整批泄漏对象都挂在它下面。另一个有用的列是 Distance,对象到 GC 根的最短引用链长度:正常随组件树走的对象 Distance 会随页面结构波动,而一批 Distance 恒定且很短的可疑对象,往往意味着被某个全局变量或模块级容器直接攥着。

我的第二、三张快照对比出来,排前面的是 Detached HTMLDivElement(#Delta 一千多)、几种 ECharts 内部对象,还有一类 Array 的 Size Delta 特别扎眼,净增了将近 8MB。三个嫌疑,正好对应后面查实的三个问题。

动手翻快照之前还有个小提醒:控制台里打印过的对象会被 DevTools 自己引用着(不然你怎么点开看),泄漏排查期间页面代码里的 console.log 会实实在在地"制造泄漏"。我们看板页恰好有几处调试期留下的 log 打印整个响应对象,排查前先注释掉、点一下 Console 的清空按钮再拍快照,不然快照里会多出一批被 DevTools 攥着的假嫌疑。

嫌疑一:Detached DOM 树

在快照的 Summary 视图顶部的 Class filter 里输入 detached,就能过滤出所有游离节点。点开一个 Detached HTMLDivElement,下半屏的 Retainers 区域会显示它的引用链——是谁攥着它不放。这是整个 Memory 面板最值钱的一块区域,读法是从对象本身往上捋,一直捋到 GC 根,链条上离你的业务代码最近的那一环就是要改的地方。

看板页这批游离 div 的引用链捋上去,停在一个叫 tooltipCache 的 Map 上。翻代码,是一个自定义的悬浮提示组件:为了"复用 DOM",它把每个数据项的提示节点缓存在模块级的 Map 里,key 是数据项的 id:

1// tooltip-manager.js(有问题的版本)
2const tooltipCache = new Map();
3
4export function getTooltip(item) {
5  if (!tooltipCache.has(item.id)) {
6    const el = document.createElement('div');
7    el.className = 'kb-tooltip';
8    el.innerHTML = renderTooltip(item);
9    el.addEventListener('click', () => locateOrder(item));
10    tooltipCache.set(item.id, el);
11  }
12  return tooltipCache.get(item.id);
13}

轮询每十秒回来一批新数据,旧数据项的 DOM 被 Vue 从页面上撤下来了,但 Map 里的引用还在——节点从文档树上摘除只是不显示了,只要还有一条 JS 引用链连着它,它和它整棵子树、以及子树上挂的事件监听就都回收不掉。 更糟的是 key 用的订单 id 每批都是新的,这个"缓存"只进不出,命中率是零,纯粹在收集尸体。注意那个 click 监听的闭包还捞着 item,等于每个游离节点还各自陪绑一份数据对象。

修法很直接:这个场景根本不需要模块级缓存,提示节点跟着组件生命周期走就行。如果确实要按 id 缓存 DOM 或元数据,也应该在数据更新时主动做淘汰:

1export function pruneTooltips(aliveIds) {
2  const alive = new Set(aliveIds);
3  tooltipCache.forEach((el, id) => {
4    if (!alive.has(id)) {
5      tooltipCache.delete(id);
6    }
7  });
8}

我顺手看了下 WeakMap 能不能救——不行,WeakMap 的弱引用在 key 上,这里 key 是字符串 id,DOM 在 value 上,照样强引用。要是反过来以 DOM 节点为 key 存元数据,WeakMap 就是正解,节点没了元数据自动跟着走。ES 提案里有 WeakRef 和 FinalizationRegistry 这种真正的弱引用值,据说明年会进标准,但现阶段只能观望,写不进生产代码。

嫌疑二:定时器和它闭包里的世界

第二个嫌疑从那批异常增长的 Array 查起。挑一个点开看 Retainers,引用链长这样:Array ← 闭包变量 context ← 一个函数 ← setInterval 的回调注册表。翻到对应代码,是看板页一个子组件里的轮询:

1// dashboard/TrendPanel.vue(有问题的版本)
2export default {
3  data() {
4    return { history: [] };
5  },
6  mounted() {
7    this.timer = setInterval(() => {
8      fetchTrend().then((res) => {
9        // 把每次的结果都留着,用于画"今日趋势"
10        this.history.push(res.data);
11        this.render(this.history);
12      });
13    }, 10000);
14  },
15  beforeDestroy() {
16    // 忘了 clearInterval(this.timer)
17  },
18};

这里其实叠了两个问题。第一个是 beforeDestroy 里忘了 clearInterval。看板页有几个可切换的 tab,切走时组件销毁了,定时器还活着,回调的闭包里攥着整个组件实例(this),组件实例又攥着它的 $el、data、子组件——一整棵对象树全部随定时器陪葬。用户白天每切一次 tab 就多一个僵尸定时器,这也解释了为什么白天使用也在缓慢变卡。setInterval 的回调被浏览器的定时器注册表引用,属于 GC 根直达的引用链,闭包里捞到什么就锁死什么,它不清理谁都别想走。

第二个问题更隐蔽:就算定时器清理得干干净净,history 这个数组本身也是无界的。每十秒 push 一条几 KB 的数据,一晚上十二个小时就是四千多条、几十 MB,这不是"忘了释放"式的泄漏,而是设计上就没给数据设上限。画趋势图根本用不到全量历史,改成定长的滑动窗口:

1this.history.push(res.data);
2if (this.history.length > MAX_POINTS) {
3  this.history.splice(0, this.history.length - MAX_POINTS);
4}

这类"合法但无界"的增长在长驻页面里和真泄漏一样致命,而且快照里更难认——它的引用链完全正常,只能靠 Size Delta 的异常增速把它揪出来。给所有长驻页面里会 push 的数组、会 set 的 Map 问一句"它的上限在哪",这次之后成了我 code review 的固定动作。

修复版本把定时器的启停收敛成一对方法,除了配对清理,还顺手加了一层看板页用得上的优化——页面被切到后台标签页时轮询没必要跑,既压内存增速也省接口:

1mounted() {
2  this.startPolling();
3  this.onVisChange = () => {
4    document.hidden ? this.stopPolling() : this.startPolling();
5  };
6  document.addEventListener('visibilitychange', this.onVisChange);
7},
8beforeDestroy() {
9  this.stopPolling();
10  document.removeEventListener('visibilitychange', this.onVisChange);
11},
12methods: {
13  startPolling() {
14    if (this.timer) return;
15    this.timer = setInterval(this.fetchAndRender, POLL_INTERVAL);
16  },
17  stopPolling() {
18    clearInterval(this.timer);
19    this.timer = null;
20  },
21},

startPolling 开头那个 if (this.timer) return 不是防御性洁癖——visibilitychange 和别的调用路径叠加时真的会重复触发,没有这个守卫就会攒出多个并行的定时器,等于修着旧泄漏造出新泄漏。仓库那台电视是常亮的、不存在切后台,但办公室里开着看板页的十几个浏览器标签页从此不再陪着空转。

嫌疑三:ECharts 实例和事件监听

第三个嫌疑是快照里那批 ECharts 内部对象。看板页的图表组件在数据更新时有一段历史遗留写法:图表配置变化比较大时,它图省事直接 echarts.init(dom) 重新初始化。ECharts 对重复 init 同一个 DOM 是有警告的(控制台确实一直有黄字,被大家习惯性无视了),旧实例不会被自动销毁,内部的 zrender 画布、缓存的 option、挂在实例上的事件全都留在内存里。修法是更新走 setOption,确需重建时先 dispose

1if (this.chart) {
2  this.chart.dispose();
3}
4this.chart = echarts.init(this.$refs.chart);

顺着图表组件又摸出一个监听器问题:每个图表组件都在 mounted 里给 window 挂了 resize 监听用于自适应,beforeDestroy 里没摘。Performance 面板里那条缓涨的 Listeners 曲线就是它贡献的。挂在 window、document 这类全局对象上的监听是和定时器同级别的"GC 根直达"引用,回调闭包里的组件实例一样会被锁死。修法没有新意——removeEventListener 配对写上;组件多了之后更好的做法是全组件共享一个统一的分发器,window 上的监听只挂一次:

1// resize-dispatcher.js
2const handlers = new Set();
3
4window.addEventListener('resize', debounce(() => {
5  handlers.forEach((fn) => fn());
6}, 100));
7
8export default {
9  on(fn) {
10    handlers.add(fn);
11  },
12  off(fn) {
13    handlers.delete(fn); // 组件销毁时必须调,Set 对回调仍是强引用
14  },
15};

这个分发器只是把"忘了摘"的爆炸半径从 window 收窄到一个 Set,该配对的 off 一个都不能少——注释里那句话是给半年后的自己写的。

这里补一个排查技巧:怀疑某个 DOM 元素上监听器泄漏时,不用翻快照,Console 里用 getEventListeners($0)($0 是 Elements 面板当前选中的元素)直接列出它身上挂的所有监听,比快照直观得多。这个 API 只在 DevTools 的 Console 里有,代码里用不了。

顺藤摸瓜:EventBus 和 keep-alive 也过了一遍安检

快照里的三个大头处理完,趁热把项目里同类风险的地方过了一遍,又扫出两处没到爆发程度的隐患,一并记下。

一处是 EventBus。我们项目里有个全局的 Vue.prototype.$bus = new Vue() 做跨组件通信,老写法的问题和 window 监听一模一样:$bus.$on 注册的回调挂在这个永生的 Vue 实例上,组件销毁不会自动解绑。翻了一圈,有两个弹窗组件只 $on$off,每开关一次弹窗就多注册一份回调——除了内存,更现实的症状是事件触发时回调被执行 N 遍,业务上表现为"偶尔一次操作弹两个提示",之前一直没往这个方向想。修法统一成对写:

1mounted() {
2  this.onRefresh = () => this.reload();
3  this.$bus.$on('order-refresh', this.onRefresh);
4},
5beforeDestroy() {
6  this.$bus.$off('order-refresh', this.onRefresh);
7},

注意 $off 要传当初注册的同一个函数引用,模板里随手写个箭头函数再想解绑就找不到人了,所以先把回调存在实例上。

组里资深前端看了修复 PR 之后推荐了一个更不容易忘的写法,Vue 的实例事件里有一组 hook: 前缀的内部事件,可以把"注册"和"注销"写在同一个地方:

1mounted() {
2  const onRefresh = () => this.reload();
3  this.$bus.$on('order-refresh', onRefresh);
4  this.$once('hook:beforeDestroy', () => {
5    this.$bus.$off('order-refresh', onRefresh);
6  });
7},

清理逻辑紧挨着创建逻辑,隔着屏幕都能看出有没有配对,比在两个生命周期钩子之间来回对眼要可靠。定时器、监听器都适用这个模式,新代码我们已经按这个风格走了。

另一处是 keep-alive。看板页有两个 tab 包在 <keep-alive> 里,切走时组件不销毁、走的是 deactivated 而不是 beforeDestroy。这本身不是泄漏——缓存组件占内存是 keep-alive 的设计意图——但它有两个连带效应要想清楚:一是被缓存组件里的定时器切走后还在跑,正确姿势是在 deactivated 里暂停、activated 里恢复;二是排查时它会污染判断,快照里一堆"没在页面上但活着"的组件实例,看起来极像 Detached DOM 的亲戚,实际是缓存的正常住户。用了 keep-alive 的页面做内存排查,要先把"缓存内的"和"泄漏的"分开,不然会追着假线索跑半天。 我们顺手给 keep-alive 加了 max 属性限制缓存数量,长驻页面里无上限的缓存和无上限的数组是同一类问题。

没用上但值得记的几个面板能力

这次排查主要靠 Comparison 视图和 Retainers 区域,Memory 面板还有几个能力这次顺带摸了一遍,记下来备用。

Summary 视图右上角可以把范围从 All objects 切成"Objects allocated between Snapshot 1 and Snapshot 2",效果和 Comparison 类似但能直接看对象详情,两个入口按习惯选一个就好。Statistics 视图给堆画了张饼图,Code、Strings、JS arrays、Typed arrays 各占多少一目了然——如果泄漏大头是字符串(比如日志、模板拼接的 HTML),从这里进比从类名列表进快。Containment 视图从 GC 根往下按持有关系浏览整个堆,翻 window 上挂了哪些全局变量时有用,平时用得少。

快照还能保存成 .heapsnapshot 文件再加载回来。这个能力在跨天对比时是刚需:周五下班前拍一张存下来,周一早上再拍一张加载对比,中间隔着整个周末的运行时间,什么增长了清清楚楚。文件不小(我们这个页面一张快照两百多 MB),排查完记得删。

另外拍快照会把页面冻住几秒到十几秒,堆越大越久,给同事演示排查过程时先说一声,不然对方会以为页面又出问题了。

复查:让曲线自己说话

三处都修完,验证方式和确诊方式对称。先用 Allocation instrumentation on timeline 模式录了几分钟——这个模式比快照更进一步,能按时间轴显示每一笔内存分配,蓝色柱子代表还活着的分配、灰色代表已回收,修复前轮询每跳一次就留下一排蓝柱子,修复后蓝柱子基本当场变灰,分配点能定位到函数级别,确认没有新的存留。这个模式开销不小、录太久面板自己都会卡,配合前面那个加速轮询的开关,几分钟就够出结论。

然后重跑堆快照三连拍,Comparison 里之前的三个大户全部归零:Detached 节点稳定在个位数(Element UI 有几个弹层节点是卸载后缓存复用的,属于库的正常行为,识别一次以后就不会再被它吓到),ECharts 对象数量恒定,Array 的 Size Delta 在滑动窗口的上限附近来回抖动。

然后是笨办法但最有说服力的:测试环境把页面挂了一整夜,第二天早上任务管理器里 210MB,Performance 曲线的谷底连线基本走平,Nodes 和 Listeners 两条线也不再爬坡。周五把修复发上线,跟仓库那边打了招呼继续观察。

那段 performance.memory 的采样代码没有下线,留成了看板页的常驻监控——这类问题修一次不难,难的是下次有人新加一个轮询面板时能不能及时发现。埋点后台配了条简单的告警线,堆内存连续两小时上涨超过阈值就在群里知会一声,算是给这个长驻页面上了道保险。

最后把这次沉淀的检查项列给组里:长驻页面的组件销毁时,定时器、全局事件监听、第三方库实例(图表、地图、编辑器)三件套必须配对清理;模块级的缓存容器必须有淘汰机制或上限;轮询累积的数据必须有窗口。道理都是去年那篇 GC 笔记里就写过的"可达性",但只有在 Memory 面板里亲手把引用链一环环捋到自己写的那行代码上,这个概念才算真正落了地。大屏这周挂到现在没再卡过,早上路过仓库特意去看了一眼,数字跳得很精神。