第 30 章

渲染性能:虚拟列表、v-memo、shallowRef 与内存泄漏排查

第30章:渲染性能——虚拟列表、v-memo、shallowRef 与内存泄漏排查

一个包含 10,000 个 DOM 节点的列表,在主流电脑上滚动时能流畅到 60 fps——但同样的数据量用普通 v-for 渲染,会让浏览器卡顿超过 1 秒。虚拟列表不是锦上添花,而是大数据量场景的生存技术。

本章核心问题:Vue 应用的性能瓶颈通常在哪里,以及如何用正确的工具(虚拟列表、v-memo、shallowRef)和正确的诊断流程解决它们?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

1.1 性能问题的正确诊断流程

在优化之前,必须先找到瓶颈。过早优化是万恶之源——对着错误的地方优化,不如不优化。

第一步:使用 Vue DevTools Performance 面板

Vue DevTools 的 Performance 面板能记录和分析组件的渲染时间。操作步骤:

  1. 打开浏览器的 Vue DevTools
  2. 切换到 "Performance" 标签
  3. 点击"开始录制"
  4. 执行让你感觉卡顿的操作(滚动、点击、数据加载)
  5. 点击"停止录制"
  6. 查看哪些组件的渲染耗时最长

判断标准

第二步:Chrome Performance 面板(更底层的分析)

Performance → Record → 操作 → Stop
重点看 Main 线程的 Task 列表:
- Long Task(超过 50ms 的任务)是性能问题的直接来源
- 点击具体 Task 查看:是 JS 执行、样式计算还是布局导致的

1.2 v-for 渲染大列表的性能极限

DOM 节点是有成本的。每个 DOM 节点在浏览器内部约消耗 1-2KB 内存,包含数十个属性和方法。渲染 1,000 个复杂列表项意味着:

实际测试数据:

列表条目数 普通 v-for 初始渲染时间(参考值) 虚拟列表渲染时间
100 条 ~20ms ~20ms(无差异)
1,000 条 ~200ms ~20ms
10,000 条 ~2000ms(2秒卡顿) ~20ms

结论:列表超过 500 条时,应该考虑虚拟列表;超过 1,000 条时,虚拟列表是必须的。

1.3 虚拟列表的基本原理

虚拟列表的核心思想:只渲染用户能看到的部分(可视区域)。

┌─────────────────────────────┐ ← 滚动容器(固定高度,overflow: auto)
│                             │
│  ┌───────────────────────┐  │ ← 撑开容器的占位元素
│  │ 幽灵元素(不可见)     │  │   高度 = 总条目数 × 每条高度
│  │ 高度 = 10000 × 50px   │  │
│  │                       │  │
│  │    item 50            │  │ ← 可视区域开始
│  │    item 51            │  │   绝对定位,top = 50 × 50px
│  │    item 52            │  │
│  │    item 53            │  │
│  │    item 54            │  │ ← 可视区域结束
│  │                       │  │
│  └───────────────────────┘  │
└─────────────────────────────┘

只有 item 50-54(约 10-20 个)被实际渲染为 DOM,其余 9,980 个条目只是虚拟存在(数组中的数据),不占用 DOM。

1.4 使用 vue-virtual-scroller

最成熟的 Vue 3 虚拟列表库是 vue-virtual-scroller

npm install vue-virtual-scroller@next

固定高度列表(最常见场景)

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

const items = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `条目 ${i}`,
  description: `这是第 ${i} 条的详情`
})));
</script>

<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="72"     <!-- 每条的固定高度(像素) -->
    key-field="id"      <!-- 用于标识每条数据的字段 -->
    v-slot="{ item }"
  >
    <div class="item">
      <h3>{{ item.name }}</h3>
      <p>{{ item.description }}</p>
    </div>
  </RecycleScroller>
</template>

<style>
.scroller {
  height: 600px;  /* 必须设置固定高度 */
}
.item {
  height: 72px;
  display: flex;
  align-items: center;
}
</style>

动态高度列表(内容高度不固定,如社交媒体帖子):

<template>
  <DynamicScroller
    :items="items"
    :min-item-size="50"  <!-- 最小条目高度 -->
    key-field="id"
  >
    <template #default="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[item.content]"  <!-- 影响高度的数据依赖 -->
        :data-index="index"
      >
        <!-- 内容高度可以不固定 -->
        <PostCard :post="item" />
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

1.5 v-memo:精准控制子树重渲染

v-memo 是 Vue 3.2 引入的指令,类似于 React 的 React.memo。它接受一个依赖数组,只有当数组中的值发生变化时,才重新渲染被标记的元素或组件:

<template>
  <!-- 只有当 item.id 或 selected 变化时才重渲染 -->
  <div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
    <!-- 如果 item.id 和 item.selected 都没变,
         整个子树的 VNode 复用,跳过 diff 和 patch -->
    <div class="item-header">{{ item.name }}</div>
    <div class="item-body">{{ item.description }}</div>
    <div class="item-footer">
      <span :class="{ selected: item.selected }">{{ item.selected ? '已选中' : '未选中' }}</span>
    </div>
  </div>
</template>

v-memo 的适用场景

  1. 大型 v-for 列表:列表有 100+ 条,每条内容复杂,但大多数情况下只有少数条目的选中状态在变化
  2. 频繁更新但大部分数据不变的场景:如实时数据表格,每秒更新一次,但 95% 的行没有变化

v-memo 的局限

<!-- 错误用法:v-memo 对 key 没有影响 -->
<div v-for="item in list" :key="item.id" v-memo="[item.version]">
  <!-- 如果 item.version 没变,整个子树不会重渲染 -->
  <!-- 但注意:如果子树中有组件,这些组件的 props 仍然会传入 -->
  <!-- v-memo 跳过的是 VNode diff,不是 props 传递 -->
  <ChildComponent :item="item" />
</div>

1.6 shallowRef 与 markRaw:减少响应式开销

Vue 3 的 reactiveref 会对对象进行深度响应式代理(Proxy),这对于大型对象可能产生明显的性能开销。

shallowRef:只对 .value 本身是响应式的,不深度追踪内部属性:

import { shallowRef, triggerRef } from 'vue';

// 场景:ECharts 实例(不需要响应式追踪内部状态)
const chartInstance = shallowRef(null);

onMounted(() => {
  chartInstance.value = echarts.init(chartEl.value);
  // 不需要 triggerRef,直接赋值新实例即可触发更新
});

// 当需要手动通知依赖更新时(内部数据变化但 .value 引用不变)
function updateChart(data) {
  chartInstance.value.setOption(data); // 直接操作实例,不触发响应式
  triggerRef(chartInstance); // 手动通知(如果需要的话)
}

markRaw:永久标记一个对象,使其永远不会被转换为响应式代理:

import { reactive, markRaw } from 'vue';

// 场景:Three.js Scene(大型 3D 对象树,不需要也不应该被代理)
const state = reactive({
  scene: markRaw(new THREE.Scene()),     // markRaw 阻止代理
  renderer: markRaw(new THREE.WebGLRenderer()),
  userData: { name: 'My 3D App' }       // 普通对象,会被代理
});

// state.scene 被访问时,Proxy 看到 markRaw 标记,不创建嵌套代理
// 避免了 Three.js 数千个内部对象被递归代理的开销

性能对比

// 测试:reactive vs shallowRef 对大型对象的初始化开销
const bigData = Array.from({ length: 10000 }, (_, i) => ({
  id: i, value: Math.random(), nested: { x: i, y: i * 2 }
}));

console.time('reactive deep');
const r1 = reactive(bigData);  // 递归代理所有 10000 个对象
console.timeEnd('reactive deep'); // ~50ms

console.time('shallowRef');
const r2 = shallowRef(bigData);  // 只代理 .value,内部不代理
console.timeEnd('shallowRef'); // ~0.1ms

Level 2 · 它是怎么运行的(3-5年经验)

2.1 虚拟列表的算法实现

理解虚拟列表的内部算法,才能在遇到问题时正确调试和优化。

固定高度虚拟列表的核心计算(O(1) 复杂度)

class FixedHeightVirtualList {
  constructor({ itemHeight, containerHeight, items }) {
    this.itemHeight = itemHeight;       // 每条固定高度(如 50px)
    this.containerHeight = containerHeight; // 容器高度(如 600px)
    this.items = items;
    
    // 可视区域能显示的条目数
    this.visibleCount = Math.ceil(containerHeight / itemHeight);
    
    // 缓冲区(额外渲染的条目数,防止滚动时出现白屏)
    this.bufferCount = Math.floor(this.visibleCount / 2);
  }
  
  // 根据滚动位置计算需要渲染的条目范围
  getVisibleRange(scrollTop) {
    // 第一个可见条目的索引(O(1) 直接计算)
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    
    // 加上缓冲区
    const bufferedStart = Math.max(0, startIndex - this.bufferCount);
    const bufferedEnd = Math.min(
      this.items.length - 1,
      startIndex + this.visibleCount + this.bufferCount
    );
    
    return { start: bufferedStart, end: bufferedEnd };
  }
  
  // 计算可见条目的偏移量(用于绝对定位)
  getOffsetForIndex(index) {
    return index * this.itemHeight; // O(1)
  }
  
  // 整个列表的总高度(用于撑开滚动容器)
  get totalHeight() {
    return this.items.length * this.itemHeight;
  }
}

动态高度虚拟列表(二分查找,O(log n) 复杂度)

当条目高度不固定时,无法用乘法直接计算偏移量,需要维护一个累积高度数组,并用二分查找定位:

class DynamicHeightVirtualList {
  constructor({ items }) {
    this.items = items;
    // 缓存已知的条目高度(初始为估算值)
    this.heightCache = new Array(items.length).fill(50); // 默认估算50px
    this.positionCache = this.buildPositionCache();
  }
  
  // 构建位置缓存(累积高度数组)
  buildPositionCache() {
    const positions = new Array(this.items.length + 1);
    positions[0] = 0;
    for (let i = 0; i < this.items.length; i++) {
      positions[i + 1] = positions[i] + this.heightCache[i];
    }
    return positions;
  }
  
  // 二分查找:找到 scrollTop 对应的起始条目索引 O(log n)
  findStartIndex(scrollTop) {
    let low = 0, high = this.items.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      const midPos = this.positionCache[mid];
      if (midPos === scrollTop) return mid;
      if (midPos < scrollTop) low = mid + 1;
      else high = mid - 1;
    }
    return Math.max(0, low - 1);
  }
  
  // 条目实际渲染后,更新高度缓存
  updateHeight(index, actualHeight) {
    if (this.heightCache[index] !== actualHeight) {
      const diff = actualHeight - this.heightCache[index];
      this.heightCache[index] = actualHeight;
      // 更新后续所有条目的位置(可能触发重新计算)
      for (let i = index + 1; i <= this.items.length; i++) {
        this.positionCache[i] += diff;
      }
    }
  }
}

2.2 Vue 3 的响应式系统开销分析

Vue 3 使用 ES6 Proxy 实现响应式,每次访问响应式对象的属性都会触发 Proxy 的 get 陷阱(trap),用于追踪依赖:

// Vue 3 Proxy 追踪的简化逻辑
function createReactiveProxy(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 每次属性访问都执行:
      track(target, TrackOpTypes.GET, key); // 记录依赖
      const result = Reflect.get(target, key, receiver);
      
      if (isObject(result)) {
        // 如果属性值是对象,递归代理(深度代理的代价)
        return reactive(result);
      }
      return result;
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      // 每次属性设置都执行:
      trigger(target, TriggerOpTypes.SET, key, value); // 触发更新
      return result;
    }
  });
}

开销分析

  1. 属性访问开销:每次读取响应式对象属性,都会额外执行 track() 函数
  2. 深度代理开销:嵌套对象首次被访问时递归创建 Proxy,大型对象树会产生大量 Proxy 实例
  3. 触发更新开销:每次属性修改都会遍历所有依赖该属性的 effect,通知它们重新运行

测量响应式开销

// 对比:响应式 vs 普通对象的读取性能
const obj = reactive({ x: 1, y: 2, z: 3 });
const plain = { x: 1, y: 2, z: 3 };

// 10,000 次属性读取
console.time('reactive read');
for (let i = 0; i < 10000; i++) { obj.x + obj.y + obj.z; }
console.timeEnd('reactive read'); // ~5ms(包含 track 开销)

console.time('plain read');
for (let i = 0; i < 10000; i++) { plain.x + plain.y + plain.z; }
console.timeEnd('plain read'); // ~0.1ms

这就是为什么对于第三方库的实例(ECharts、Three.js、Leaflet),应该用 markRawshallowRef 而不是 reactive

2.3 Vue 应用的 5 类内存泄漏场景

内存泄漏在 SPA 中比多页面应用更严重,因为用户可能在一个页面停留数小时,GC 无法回收的内存会持续累积。

场景1:全局事件监听未清理

<script setup>
import { onMounted, onUnmounted } from 'vue';

// 错误:没有清理
onMounted(() => {
  // 这个监听器持有对组件的引用,组件卸载后 GC 无法回收
  window.addEventListener('resize', handleResize);
  document.addEventListener('keydown', handleKeydown);
});

// 没有 onUnmounted!→ 每次组件挂载都添加一个新的监听器,永不清理

// 正确做法:
onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize); // 必须移除!
});

// 或者使用 useEventListener composable(自动清理)
import { useEventListener } from '@vueuse/core';
useEventListener(window, 'resize', handleResize); // 自动在 onUnmounted 时清理
</script>

场景2:setInterval/setTimeout 未清理

<script setup>
import { onMounted, onUnmounted } from 'vue';

let intervalId = null;

onMounted(() => {
  intervalId = setInterval(() => {
    fetchLatestData(); // 这个闭包持有对组件数据的引用
  }, 5000);
});

// 正确:必须清理
onUnmounted(() => {
  if (intervalId) {
    clearInterval(intervalId);
    intervalId = null;
  }
});
</script>

场景3:Pinia store 持有卸载组件的 ref

// 错误场景:store 中存储了对组件 ref 的直接引用
const useUIStore = defineStore('ui', {
  state: () => ({
    // 错误!将组件的 DOM ref 存储在全局 store 中
    // 组件卸载后,store 仍持有引用,GC 无法回收 DOM
    activeElementRef: null,
  }),
  actions: {
    setActiveElement(el) {
      this.activeElementRef = el; // 持有 DOM 元素的引用
    }
  }
});

// 组件中:
onUnmounted(() => {
  // 必须清除 store 中的引用!
  uiStore.activeElementRef = null;
});

场景4:provide 的响应式数据持有大型对象

// 父组件(根级别)
const bigDataset = reactive({
  users: [], // 可能有数万条用户数据
  cache: new Map(), // 可能缓存了大量计算结果
});

provide('dataset', bigDataset);

// 问题:即使使用 dataset 的子组件已经卸载,
// provide 的数据仍然存活(因为父组件还在),
// 且数据随时间增长不受控制

// 正确做法:
// 1. 限制缓存大小(LRU 缓存)
// 2. 在子组件 onUnmounted 时通知父组件清理相关缓存

场景5:v-for key 设置不当导致组件频繁销毁重建

<!-- 错误:使用随机数或时间戳作为 key -->
<template>
  <div v-for="item in items" :key="Math.random()">
    <!-- 每次重渲染,所有条目都会被销毁并重新创建 -->
    <!-- 如果组件内有 setInterval,会不断创建新的定时器 -->
    <HeavyComponent :data="item" />
  </div>
</template>

<!-- 正确:使用稳定的 ID -->
<template>
  <div v-for="item in items" :key="item.id">
    <HeavyComponent :data="item" />
  </div>
</template>

2.4 用 Chrome Memory 面板排查内存泄漏

完整的内存泄漏排查工作流:

步骤1:建立基线
│ 打开 Chrome DevTools → Memory 面板
│ 点击"Take Heap Snapshot"(基线快照)
│ 记录当前堆内存大小(如 15 MB)
│
步骤2:触发可疑操作
│ 反复执行可能导致泄漏的操作:
│   - 打开并关闭某个对话框 10 次
│   - 在路由间来回切换 10 次
│   - 加载并卸载某个组件 10 次
│
步骤3:强制 GC
│ 点击 DevTools 左上角的垃圾桶图标(Collect garbage)
│ 等待 GC 完成
│
步骤4:取第二次快照
│ 再次点击"Take Heap Snapshot"
│ 如果内存明显增长(如从 15 MB → 25 MB),说明存在泄漏
│
步骤5:比较快照
│ 在快照列表中选择第二个快照
│ 将"Summary"改为"Comparison"(与第一个快照对比)
│ 查看 ΔSize(增加的内存)和 ΔCount(增加的对象数)
│
步骤6:定位泄漏对象
│ 按 ΔSize 降序排列
│ 展开增长最多的类型(通常是 Detached DOM tree 或 Array)
│ 查看对象的 Retainers(被谁持有,防止 GC)
│ 追踪到具体的 Vue 组件或 store

实际案例:发现 Detached HTMLDivElement × 480

这意味着有 480 个 DOM 节点已经从文档中分离,但仍被 JavaScript 代码持有,无法被 GC 回收。

排查路径:

  1. 展开其中一个 Detached HTMLDivElement
  2. 查看 Retainers 树
  3. 找到持有该节点的 JavaScript 对象(通常是事件监听器的闭包或全局变量)
  4. 定位到具体代码行,添加清理逻辑

Level 3 · 设计文档与源码(资深开发者)

3.1 v-memo 的源码实现

v-memo 在编译后会生成对 withMemo 的调用:

// packages/runtime-core/src/helpers/withMemo.ts
export function withMemo(
  memo: any[],           // 依赖数组
  render: () => VNode[], // 渲染函数
  cache: any[],          // 缓存(来自组件实例)
  index: number          // 缓存索引
) {
  const cached = cache[index] as VNode | undefined;
  
  if (cached && isMemoSame(cached, memo)) {
    // 依赖未变化:直接返回缓存的 VNode
    return cached;
  }
  
  // 依赖变化:重新渲染
  const ret = render();
  
  // 将依赖数组存储在 VNode 上,用于下次比较
  (ret as any).memo = memo.slice();
  
  // 更新缓存
  return (cache[index] = ret as VNode);
}

function isMemoSame(cached: VNode, memo: any[]) {
  const prev: any[] = (cached as any).memo;
  if (prev.length !== memo.length) return false;
  
  for (let i = 0; i < memo.length; i++) {
    if (!Object.is(prev[i], memo[i])) {
      // 使用 Object.is 进行严格相等比较(NaN 安全)
      return false;
    }
  }
  return true;
}

v-memo 的编译产物

<!-- 源码 -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
  {{ item.name }}
</div>
// 编译后(简化)
renderList(list, (item, index) => {
  return withMemo(
    [item.selected],          // memo 数组
    () => createElementVNode('div', { key: item.id }, item.name),
    _cache,                   // 组件实例的缓存
    index                     // 以 index 为 key 的缓存槽位
  );
})

3.2 shallowRef 与 ref 的内部差异

// packages/reactivity/src/ref.ts

// ref:深度响应式
export function ref<T>(value?: T) {
  return createRef(value, false); // shallow = false
}

// shallowRef:浅层响应式
export function shallowRef<T>(value?: T) {
  return createRef(value, true); // shallow = true
}

function createRef(rawValue: unknown, shallow: boolean) {
  const ref = {
    __v_isRef: true,
    __v_isShallow: shallow,
    dep: undefined,
    _rawValue: rawValue,
    _value: shallow ? rawValue : toReactive(rawValue),
    //                           ↑
    //                           深度 ref 在这里对值调用 reactive()
    //                           shallowRef 不调用,直接存储原始值
    
    get value() {
      trackRefValue(this);
      return this._value;
    },
    set value(newVal) {
      const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
      newVal = useDirectValue ? newVal : toRaw(newVal);
      
      if (hasChanged(newVal, this._rawValue)) {
        this._rawValue = newVal;
        this._value = useDirectValue ? newVal : toReactive(newVal);
        triggerRefValue(this, newVal);
      }
    }
  };
  return ref;
}

关键区别

3.3 Vue 3 组件缓存与 keepAlive 内存权衡

<KeepAlive> 通过缓存组件的 VNode 和 DOM 来避免重复挂载/卸载的开销,但这以内存为代价:

// packages/runtime-core/src/components/KeepAlive.ts(简化)

// 缓存策略:LRU(最近最少使用)
const cache: Cache = new Map();  // key → VNode 映射
const keys: Keys = new Set();    // 维护访问顺序

function onCacheEviction(key: CacheKey) {
  // 当超过 max 数量时,移除最旧的缓存
  unmount(cache.get(key)!);
}

// 控制最大缓存数量(防止无限内存增长)
// <KeepAlive :max="10"> → 最多缓存10个组件实例

内存权衡建议

<!-- 只缓存特定组件(白名单) -->
<KeepAlive :include="['Home', 'UserProfile']" :max="5">
  <RouterView />
</KeepAlive>

<!-- 排除某些组件不缓存(黑名单) -->
<KeepAlive :exclude="['OrderDetail', 'PaymentPage']">
  <RouterView />
</KeepAlive>

Level 4 · 边界与陷阱(全体适用)

陷阱 1:虚拟列表的滚动位置重置问题

错误场景:数据更新后,虚拟列表的滚动位置被重置到顶部:

<!-- 错误:每次 items 变化,整个 key 变了,RecycleScroller 重新挂载 -->
<RecycleScroller :items="items" :key="items.length" :item-size="50" />

正确做法:保持 RecycleScroller 的 key 稳定,数据变化只更新 items prop:

<!-- 正确:key 不变,只更新 items -->
<RecycleScroller :items="items" :item-size="50" key-field="id" />

如果需要主动重置滚动位置:

<script setup>
const scrollerRef = ref(null);

function scrollToTop() {
  scrollerRef.value.scrollToItem(0);
}
</script>

<template>
  <RecycleScroller ref="scrollerRef" :items="items" :item-size="50" />
</template>

陷阱 2:v-memo 与 v-if 的组合导致意外行为

错误场景

<template>
  <div v-for="item in items" :key="item.id" v-memo="[item.visible]">
    <!-- 问题:当 item.visible 从 false 变为 true 时,
         v-memo 检测到变化,会重新渲染整个子树
         但 v-if 内部基于 item.data 的内容不在 memo 数组中,
         即使 item.data 变了,也不会重渲染 -->
    <div v-if="item.visible">{{ item.data }}</div>
  </div>
</template>

规则v-memo 的依赖数组必须包含所有会影响子树渲染的响应式数据。

<!-- 正确:memo 包含所有影响渲染的数据 -->
<div v-memo="[item.visible, item.data]">
  <div v-if="item.visible">{{ item.data }}</div>
</div>

陷阱 3:markRaw 对象被赋值给响应式数据的子属性后失效

错误场景

const state = reactive({
  chart: null
});

// 初始化时正确使用了 markRaw
state.chart = markRaw(echarts.init(el));

// 后来某处代码错误地将 state.chart 重新赋值
// 而新值没有 markRaw
state.chart = echarts.init(el); // 没有 markRaw!
// Vue 会尝试对 ECharts 实例进行响应式代理,可能导致性能问题或错误

正确做法:一旦决定用 markRaw,每次赋值都要 markRaw:

function initChart(el) {
  state.chart = markRaw(echarts.init(el)); // 每次都 markRaw
}

function reinitChart(el) {
  state.chart?.dispose(); // 先销毁旧实例
  state.chart = markRaw(echarts.init(el)); // 每次都 markRaw
}

陷阱 4:内存泄漏的计时器引用组件数据

复杂的泄漏场景

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const data = ref([]);
let intervalId;

onMounted(() => {
  // 此处的箭头函数是闭包,持有对 data 的引用
  intervalId = setInterval(() => {
    // data 是响应式 ref,通过闭包被 setInterval 持有
    data.value = fetchData();
  }, 1000);
});

// 如果组件在路由切换时卸载,但没有 onUnmounted 清理...
// setInterval 回调还在每秒执行,data.value 还在被更新
// 但组件的 DOM 和大部分内存应该被回收了...
// 然而 ref 对象本身(包含响应式追踪器)无法被 GC,因为被 setInterval 引用

// 内存泄漏:即使 DOM 被移除,每 1 秒仍会执行一次数据请求和响应式更新
</script>

正确代码(带完整清理):

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const data = ref([]);
let intervalId = null;

onMounted(() => {
  intervalId = setInterval(async () => {
    data.value = await fetchData();
  }, 1000);
});

onUnmounted(() => {
  clearInterval(intervalId);
  intervalId = null;
  data.value = []; // 释放数据引用(可选,但有助于 GC)
});
</script>

陷阱 5:虚拟列表中的图片懒加载与 IntersectionObserver 的冲突

问题场景:在虚拟列表中使用 IntersectionObserver 实现图片懒加载:

// 图片懒加载指令
app.directive('lazy', {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        el.src = binding.value;
        observer.disconnect();
      }
    });
    observer.observe(el);
    // 问题:每个 img 元素都创建了一个 IntersectionObserver 实例
    // 虚拟列表中,同一个 DOM 节点被复用(回收再利用)
    // 旧的 observer 没有被清理,新的图片 src 又不会被设置
  }
});

正确做法:在虚拟列表环境中,需要在指令的 unmountedupdated 钩子中正确清理:

app.directive('lazy', {
  mounted(el, binding) {
    el._lazyObserver = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        el.src = binding.value;
        el._lazyObserver.disconnect();
        el._lazyObserver = null;
      }
    });
    el._lazyObserver.observe(el);
  },
  updated(el, binding) {
    // 虚拟列表复用 DOM 时,binding.value 会变化
    // 需要重置并重新观察
    if (binding.value !== binding.oldValue) {
      if (el._lazyObserver) {
        el._lazyObserver.disconnect();
      }
      el.src = ''; // 重置 src
      el._lazyObserver = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          el.src = binding.value;
          el._lazyObserver.disconnect();
          el._lazyObserver = null;
        }
      });
      el._lazyObserver.observe(el);
    }
  },
  unmounted(el) {
    if (el._lazyObserver) {
      el._lazyObserver.disconnect();
      el._lazyObserver = null;
    }
  }
});

本章小结

  1. 诊断先于优化:先用 Vue DevTools Performance 面板定位耗时最长的组件,再用 Chrome Performance 面板分析 Long Task,最后才针对性优化——跳过诊断的优化往往事倍功半。

  2. 500 条以上考虑虚拟列表:固定高度列表用 RecycleScroller 配合 O(1) 的索引计算,动态高度用 DynamicScroller 配合二分查找;虚拟列表离开元素时要正确处理 DOM 复用场景(如图片懒加载)。

  3. 大型对象必须用 shallowRef 或 markRaw:ECharts 实例、Three.js Scene 等第三方库对象不应被 Vue 深度代理——shallowRef 仅追踪 .value 的替换,markRaw 完全排除响应式追踪,两者根据是否需要响应式更新来选择。

  4. 内存泄漏的五类 Vue 专属场景:全局事件监听、定时器、store 持有 DOM 引用、provide 数据无限增长、unstable key 导致频繁重建——每个 onMounted 内的资源获取,都应有对应的 onUnmounted 清理。

  5. Heap Snapshot 比较是定位泄漏的金标准:基线快照 → 操作 10 次 → GC → 第二次快照 → 比较 ΔSize,找到 Detached DOM 和 ΔCount 增长最多的对象类型,追踪 Retainer 链到具体代码行。

本章评分
4.7  / 5  (3 评分)

💬 留言讨论