后台开两天堆到 1.8 GB:Vue3 SPA 内存泄漏定位

用久了变卡、重开就好,是内存泄漏的典型特征。Memory 快照差集里一万两千个 Detached DOM,引用链指向 37 个没 dispose 的 ECharts 实例和无上限的 keep-alive。

客服同学反馈了一个怪现象:后台系统早上打开很流畅,用到下午就开始卡,切换左侧菜单要点两下才有反应,最后连滚动都一顿一顿。她自己总结出一套办法——关掉标签页重新打开,立刻恢复正常。

第一次听到这个描述我就有数了:这是典型的内存泄漏。如果是接口慢,刷新不会变好;如果是 CPU 抢占,也不会随使用时长单调恶化。只有「用得越久越慢、重开就恢复」这个特征,指向内存只增不减。

真正让我意外的是量级。我在她的机器上打开 chrome://performance 看了半天,又在控制台打了 performance.memory,usedJSHeapSize 已经到了 1.8 GB,而正常打开时是 96 MB。

一、先确认是泄漏,不是缓存

内存涨有两种可能:一是缓存策略导致的正常增长(比如列表数据本地留存),二是真的泄漏(对象已经不该被引用,却还被持有)。

区分方法很朴素:做一次「强制 GC 后的重复操作」,看基线是否台阶式上升。

// 在 Console 里执行,Chrome 需要开 --js-flags="--expose-gc"
// 或者在 DevTools 的 Performance 面板点垃圾桶图标手动 GC
async function probe() {
  const samples = [];
  for (let i = 0; i < 5; i++) {
    // 模拟一次完整的「进入订单页 -> 停留 -> 返回列表」
    await router.push('/orders');
    await new Promise(r => setTimeout(r, 3000));
    await router.push('/dashboard');
    await new Promise(r => setTimeout(r, 1500));
    window.gc && window.gc();
    samples.push(Math.round(performance.memory.usedJSHeapSize / 1048576));
  }
  console.table(samples.map((mb, i) => ({ round: i + 1, heapMB: mb })));
}
probe();

结果是这样:96 → 148 → 201 → 254 → 307。每一轮稳定上涨 53 MB,GC 之后不回落。如果是缓存,第二轮之后应该趋于平稳,因为数据总量是固定的。台阶式单调上升,就是泄漏。

53 MB 这个数字很有信息量。它对应的是「一次订单页停留」产生的垃圾量。订单页是个长列表,每行带缩略图,一次加载 200 条,2 万条数据滚动加载完大概是这个体积。

二、内存快照对比,找出谁在留着对象

确认泄漏之后,下一步是找「谁持有」。Chrome DevTools 的 Memory 面板里,Comparison 视图可以做两次快照的差集,这是最直接的路。

操作顺序很重要,错了结论就废了:

  1. 打开页面,做一次 GC,拍快照 A
  2. 进入订单页,滚动加载 200 条,返回列表
  3. 做一次 GC,拍快照 B
  4. 在 B 上选 Comparison,Base 选 A

差集里排第一的是 Detached HTMLDivElement,新增 12480 个,Retained Size 合计 46 MB。Detached 意味着这些 DOM 节点已经从文档树上摘下来了,但 JS 里还有引用指着它们,所以回收不掉。

点开其中一个的 Retainers(引用链),看到这样一条:

Detached HTMLDivElement
  ← __vueParentComponent (VueComponent)
    ← _echarts_instance_ (Object)
      ← chart (EChartsInstance)
        ← this.charts (Array, length 37)
          ← VueComponent <OrderTrendChart>
            ← __vueParentComponent
              ← ... 一直到 window

引用链把问题说得很清楚了:37 个 ECharts 实例被一个数组持有,每个实例内部又通过 __vueParentComponent 反向引用着 Vue 组件实例,组件实例又引用着它的 DOM 子树。这些图表所在的路由早就离开了,DOM 也摘掉了,但 this.charts 数组还在 window 可达的链上。

三、四处泄漏,逐个对账

顺着这条链查下去,一共找到四个地方。

第一处,ECharts 实例没 dispose。

原来的写法在 mounted 里把 document.querySelectorAll 的结果逐个 echarts.init,然后把实例 push 进 this.charts 数组,并且没有写 beforeUnmount。

三个问题叠在一起:init 之前没判断 echarts.getInstanceByDom(el),导致同一个 DOM 上反复创建实例;组件卸载时没有 dispose;resize 监听没有移除。

echarts.init 会在 DOM 元素上挂一个 _echarts_instance_ 属性,并把实例注册到全局表里。不 dispose 的话,这个实例会一直留在全局表中,同时它内部持有的 canvas、事件绑定、以及通过 __vueParentComponent 反向引用到的组件树全部无法回收。

第二处,事件监听没解绑。 addEventListener 传的是组件方法引用,组件销毁后监听器依然挂在 window 上,this 指向的组件实例因此无法回收。而且同一个组件挂载 5 次就有 5 个同名监听器同时响应一次 resize,每次 resize 触发 5 次重排——这也是「越用越卡」的另一半原因,不只是内存问题。

第三处,watch 和定时器没停。

setInterval 里的回调闭包持有组件实例,只要定时器不清,组件就一直活着。更糟的是轮询接口还在继续发请求,一个已经离开的页面在后台每 5 秒打一次接口——这种问题在服务端看就是「来源不明的流量」。

$watch 在组件内用 this.$watch 创建的,理论上会随组件卸载自动停止,但如果这个 watcher 的回调里引用了外部闭包变量,或者 watcher 是在 setup 外创建并存在全局的,就不会自动清理。

第四处,keep-alive 无上限。

这一处最隐蔽。我们的路由配置里对整个 Layout 用了 <keep-alive>,本意是让常用页面的状态保留,避免来回切换重新加载。

<!-- 出问题的写法 -->
<router-view v-slot="{ Component }">
  <keep-alive>
    <component :is="Component" />
  </keep-alive>
</router-view>

没有 include、没有 max。结果是:用户点过的每一个路由页面都会被缓存,永不释放。后台系统有 40 多个路由,用户滚一遍就缓存 40 个组件树,加上每个组件里的列表数据、图表实例,这就是那 1.8 GB 的主要来源。

<keep-alive> 缓存的是组件实例,不是 DOM 快照。缓存期间组件的响应式数据、watcher、事件监听全都还在,只是 DOM 被移出文档。所以它既是内存增长源,也是「切换变慢」的原因——缓存太多,每次匹配 include 都要遍历一遍。

四、修复

ECharts 部分,改成复用 + 显式销毁:

import * as echarts from 'echarts';
import { onBeforeUnmount, onMounted, ref } from 'vue';

export default {
  setup() {
    const containers = ref([]);
    const charts = new Map();

    const renderChart = (el, type) => {
      if (!el) return;
      // 关键:先查已有实例,避免重复 init
      let inst = echarts.getInstanceByDom(el);
      if (!inst) {
        inst = echarts.init(el, null, { renderer: 'canvas' });
        charts.set(el, inst);
      }
      inst.setOption(buildOption(type), true); // true = 不合并,彻底替换
    };

    const handleResize = () => charts.forEach(c => c.resize());

    onMounted(() => {
      containers.value.forEach(el => renderChart(el, el.dataset.type));
      window.addEventListener('resize', handleResize, { passive: true });
    });

    onBeforeUnmount(() => {
      window.removeEventListener('resize', handleResize);
      charts.forEach(c => c.dispose());   // 必须 dispose,不能只置 null
      charts.clear();
    });

    return { containers };
  }
};

dispose() 和「把引用置 null」是两回事。置 null 只是断开你自己这一条引用,ECharts 内部注册在 DOM 上的 _echarts_instance_ 和全局实例表还在,DOM 依然回收不掉。必须调 dispose()。

事件监听与定时器部分,统一收敛到两个组合式 hook:useWindowEvent(type, handler) 在 onMounted 里绑定、onBeforeUnmount 里解绑;usePolling(fn, ms) 在卸载时 clearInterval,并监听 visibilitychange,页面隐藏时暂停轮询。onBeforeUnmount 的清理是同步执行的,比 unmounted 更早,能保证在 DOM 真正移除前完成解绑。

keep-alive 部分,加上 include 白名单和 max:

<router-view v-slot="{ Component, route }">
  <keep-alive :include="cachedViews" :max="6">
    <component :is="Component" :key="route.path" />
  </keep-alive>
</router-view>

cachedViews 由路由的 meta.keepAlive 决定:遍历 router.getRoutes(),筛出 meta.keepAlive 为真的路由名,这样只有标记过的页面进缓存,加页面时改路由配置即可。

max="6" 是 LRU 淘汰,超出就丢最久没用的。include 用组件 name 匹配,所以组件必须有 name——用 <script setup> 的话默认没有 name,要加 defineOptions({ name: 'OrderList' }),这是个很容易踩的坑,不写的话 include 完全不生效,看起来像「配了没用」。

我们最后只给三个页面开了 keepAlive:订单列表、客户列表、报表看板。其余全部不缓存。

五、验证

改完之后用同一套脚本再测一遍:

轮次 修复前 heap (MB) 修复后 heap (MB) 修复后 DOM 节点数
初始 96 92 3,180
第 2 轮 148 95 3,205
第 3 轮 201 97 3,212
第 4 轮 254 96 3,198
第 5 轮 307 98 3,220
满载 40 路由切换后 1,830 214 8,460

满载那一行值得看一眼:214 MB 不是零,因为 keep-alive 确实缓存了 6 个页面,这是设计内的开销。关键是它不再随着使用时长无限增长。

我还在 CI 里加了一个冒烟检查:用 Puppeteer 跑 20 轮固定的路由切换,强制 GC 后比较堆增长比例,超过 15% 直接让流水线失败。这个脚本能拦住绝大多数回归,成本只有一次浏览器启动的时间。

小结

  • 「用得越久越慢、重开就恢复」是内存泄漏的典型特征;用强制 GC 后的重复操作看基线是否台阶式上升,可以把它和正常缓存区分开。
  • Memory 面板的 Comparison 视图做两次快照差集,Detached DOM 数量暴增就是泄漏的信号,顺着 Retainers 链条几乎总能定位到持有者。
  • ECharts 必须调 dispose(),置 null 不够;init 之前要用 getInstanceByDom 判断,避免同一 DOM 反复创建实例。
  • 事件监听、定时器、$watch 都要在 onBeforeUnmount 里显式清理;它们不仅泄漏内存,还会让已离开的页面继续发请求。
  • 无 include、无 max 的 <keep-alive> 会缓存所有访问过的路由,是后台系统内存暴涨的常见主因;max 走 LRU,include 依赖组件 name,<script setup> 下要手写 defineOptions。
  • 用 Puppeteer 在 CI 里跑固定轮数的路由切换并断言堆增长比例,能以很低的成本拦住这类回归。
查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。