渲染性能:虚拟列表、v-memo、shallowRef 与内存泄漏排查
第30章:渲染性能——虚拟列表、v-memo、shallowRef 与内存泄漏排查
一个包含 10,000 个 DOM 节点的列表,在主流电脑上滚动时能流畅到 60 fps——但同样的数据量用普通
v-for渲染,会让浏览器卡顿超过 1 秒。虚拟列表不是锦上添花,而是大数据量场景的生存技术。
本章核心问题:Vue 应用的性能瓶颈通常在哪里,以及如何用正确的工具(虚拟列表、v-memo、shallowRef)和正确的诊断流程解决它们?
读完本章你将理解:
- 虚拟列表的实现原理(固定高度 O(1) vs 动态高度二分查找),以及何时必须使用虚拟列表
v-memo与shallowRef/markRaw的适用场景,以及它们如何减少不必要的响应式开销- Vue 专属的 5 类内存泄漏场景,以及用 Chrome Memory Heap Snapshot 定位泄漏的完整工作流
Level 1 · 你需要知道的(1-3年经验)
1.1 性能问题的正确诊断流程
在优化之前,必须先找到瓶颈。过早优化是万恶之源——对着错误的地方优化,不如不优化。
第一步:使用 Vue DevTools Performance 面板
Vue DevTools 的 Performance 面板能记录和分析组件的渲染时间。操作步骤:
- 打开浏览器的 Vue DevTools
- 切换到 "Performance" 标签
- 点击"开始录制"
- 执行让你感觉卡顿的操作(滚动、点击、数据加载)
- 点击"停止录制"
- 查看哪些组件的渲染耗时最长
判断标准:
- 单次组件渲染 > 16ms → 会导致帧率低于 60fps,用户感知到卡顿
- 单次组件渲染 > 100ms → 用户体验严重降级
第二步:Chrome Performance 面板(更底层的分析)
Performance → Record → 操作 → Stop
重点看 Main 线程的 Task 列表:
- Long Task(超过 50ms 的任务)是性能问题的直接来源
- 点击具体 Task 查看:是 JS 执行、样式计算还是布局导致的
1.2 v-for 渲染大列表的性能极限
DOM 节点是有成本的。每个 DOM 节点在浏览器内部约消耗 1-2KB 内存,包含数十个属性和方法。渲染 1,000 个复杂列表项意味着:
- 创建 1,000+ DOM 节点
- 浏览器需要计算这 1,000+ 个节点的样式、布局和绘制
- 每次数据更新,Vue 需要 diff 这 1,000+ 个 VNode
实际测试数据:
| 列表条目数 | 普通 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 的适用场景:
- 大型 v-for 列表:列表有 100+ 条,每条内容复杂,但大多数情况下只有少数条目的选中状态在变化
- 频繁更新但大部分数据不变的场景:如实时数据表格,每秒更新一次,但 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 的 reactive 和 ref 会对对象进行深度响应式代理(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;
}
});
}
开销分析:
- 属性访问开销:每次读取响应式对象属性,都会额外执行
track()函数 - 深度代理开销:嵌套对象首次被访问时递归创建 Proxy,大型对象树会产生大量 Proxy 实例
- 触发更新开销:每次属性修改都会遍历所有依赖该属性的 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),应该用 markRaw 或 shallowRef 而不是 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 回收。
排查路径:
- 展开其中一个
Detached HTMLDivElement - 查看 Retainers 树
- 找到持有该节点的 JavaScript 对象(通常是事件监听器的闭包或全局变量)
- 定位到具体代码行,添加清理逻辑
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;
}
关键区别:
ref(bigObject)→ 内部调用reactive(bigObject)→ 递归代理所有属性shallowRef(bigObject)→ 直接存储原始对象,不代理内部属性
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 又不会被设置
}
});
正确做法:在虚拟列表环境中,需要在指令的 unmounted 和 updated 钩子中正确清理:
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;
}
}
});
本章小结
-
诊断先于优化:先用 Vue DevTools Performance 面板定位耗时最长的组件,再用 Chrome Performance 面板分析 Long Task,最后才针对性优化——跳过诊断的优化往往事倍功半。
-
500 条以上考虑虚拟列表:固定高度列表用
RecycleScroller配合 O(1) 的索引计算,动态高度用DynamicScroller配合二分查找;虚拟列表离开元素时要正确处理 DOM 复用场景(如图片懒加载)。 -
大型对象必须用 shallowRef 或 markRaw:ECharts 实例、Three.js Scene 等第三方库对象不应被 Vue 深度代理——shallowRef 仅追踪
.value的替换,markRaw 完全排除响应式追踪,两者根据是否需要响应式更新来选择。 -
内存泄漏的五类 Vue 专属场景:全局事件监听、定时器、store 持有 DOM 引用、provide 数据无限增长、unstable key 导致频繁重建——每个 onMounted 内的资源获取,都应有对应的 onUnmounted 清理。
-
Heap Snapshot 比较是定位泄漏的金标准:基线快照 → 操作 10 次 → GC → 第二次快照 → 比较 ΔSize,找到 Detached DOM 和 ΔCount 增长最多的对象类型,追踪 Retainer 链到具体代码行。