v-memo 与虚拟列表
Vue 性能优化 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这一节解决「列表太长」的两类问题:不必要的重渲染(v-memo)和DOM 节点太多(虚拟列表)。学完你能判断一个 1000 行列表该用哪种手段,并写出一个可用的小型虚拟列表。
问题场景
一个日志查看器渲染 2000 条记录,每次追加一条新日志,整个列表卡 300ms。另一个场景:表格每行有复杂单元格(头像、标签、进度条),勾选一行时所有行都在重渲染。前者是 DOM 数量问题,后者是 diff 工作量问题——对应两种不同的优化手段。
核心解释
v-memo:给元素或组件声明依赖数组,只有数组里的值变化时才参与 diff:
<template>
<div v-for="row in rows" :key="row.id" v-memo="[row.selected, row.version]">
<ExpensiveCell :row="row" />
</div>
</template>
语义:v-memo="[a, b]" 等价于「a、b 都没变时,这段子树跳过更新」。它只对 v-for 内的元素有意义(官方明确说这是唯一有价值的场景),而且要保证依赖数组覆盖了所有会影响渲染的值——漏了就会渲染陈旧数据。多数项目用不到 v-memo:Vue 的编译优化(静态提升、patchFlag)已经处理了大部分场景;它适合「列表项特别重 + 更新特别频繁」的组合。
虚拟列表:只渲染可视区域的几十个节点,滚动时替换内容。核心三要素:容器高度(撑出滚动条)、可视区计算(滚动位置 / 行高)、窗口切片(起始索引到结束索引):
<script setup>
import { computed, ref } from "vue";
const props = defineProps({
items: { type: Array, required: true },
itemHeight: { type: Number, default: 40 },
viewportHeight: { type: Number, default: 400 },
overscan: { type: Number, default: 5 },
});
const scrollTop = ref(0);
const start = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
);
const visibleCount = computed(() =>
Math.ceil(props.viewportHeight / props.itemHeight) + props.overscan * 2
);
const end = computed(() =>
Math.min(props.items.length, start.value + visibleCount.value)
);
const visible = computed(() => props.items.slice(start.value, end.value));
const offsetY = computed(() => start.value * props.itemHeight);
const totalHeight = computed(() => props.items.length * props.itemHeight);
</script>
<template>
<div
class="viewport"
:style="{ height: viewportHeight + 'px', overflowY: 'auto' }"
@scroll="scrollTop = $event.target.scrollTop"
>
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(item, i) in visible"
:key="items[start + i].id"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="start + i" />
</div>
</div>
</div>
</div>
</template>
要点:外层容器固定高度并滚动;内层撑出总高度让滚动条比例正确;可视窗口用 translateY 偏移。overscan 是上下多渲染几行,避免快速滚动时白屏。这个实现假设行高固定——行高不定时要用测量方案(ResizeObserver + 位置缓存),复杂度翻倍,考虑用成熟库。
scroll 事件频率:滚动事件本身很密,但这里只是给 ref 赋值,Vue 的批处理天然合并;不要在下拉刷新等场景里同步做重活。
示例拆解
用虚拟列表把一个 5000 条的日志列表跑起来,并对比 DOM 节点数:
<script setup>
import { ref } from "vue";
import VirtualList from "./VirtualList.vue";
const logs = ref(
Array.from({ length: 5000 }, (_, i) => ({
id: i,
text: `日志 #${i}:请求完成 ${i * 7}ms`,
}))
);
</script>
<template>
<VirtualList :items="logs" :item-height="36" :viewport-height="480">
<template #default="{ item }">
<p class="log-line">{{ item.text }}</p>
</template>
</VirtualList>
</template>
验证:打开 DevTools 的 Elements 面板搜索 .log-line,节点数应该在 30 上下(而不是 5000);滚动到底部,最后一条日志可见;滚动过程中无白屏。这就是虚拟列表的验收标准。
动手任务
- 完成上面的虚拟列表,验证节点数量与滚动体验。
- 给虚拟列表加「跟随最新日志」开关:开启时新日志追加后自动滚到底部(
scrollTop = scrollHeight)。 - 给一个中型列表(200 行、每行一个重组件)加 v-memo,用 Performance 面板对比勾选操作的耗时。
常见错误
- v-memo 依赖漏项:渲染陈旧数据,且很难发现。依赖数组要覆盖所有渲染输入。
- 虚拟列表用 index 当 key:滚动窗口切换时节点复用错位,行内状态串行。用数据 id。
- 虚拟列表容器高度没设:滚动条不出现,或者总高度算错导致滚不到底。
- 对短列表用虚拟列表:50 行的列表直接渲染更快,虚拟化带来的复杂度不值得。
检查与复盘
- 能说出 v-memo 的语义与唯一适用场景
- 能手写固定行高的虚拟列表核心计算
- 能判断一个列表该用 v-memo、虚拟列表还是都不需要
参考资料
- Vue 官方文档「v-memo」:语义与限制
- Vue 官方文档「性能优化」:大列表的处理建议
读完这一课了?
标记完成后,工作台会安排接下来的学习。