Appearance
场景:虚拟列表
要点速览
- 痛点:一次性渲染海量列表(1 万+)导致页面卡顿与内存膨胀
- 三种策略:懒加载、时间分片、虚拟列表;本质和适用场景不同
- 虚拟列表核心:仅渲染可视区域元素,随滚动替换内容,保持固定 DOM 数量
- 关键实现:占位容器高度、索引计算、渲染位移、节流与缓冲
问题与目标
- 目标:在海量数据场景下保持滚动流畅、首屏快速、内存稳定。
- 挑战:全量渲染会产生成千上万的 DOM 节点,布局与重绘成本极高,交互卡顿。
方案对比
懒加载
- 原理:仅在元素进入视口时加载资源(图片等),离开视口不移除 DOM。
- 优点:降低初次带宽消耗,提升首屏体验;适用于图文内容延迟加载。
- 缺点:DOM 依旧全量存在,列表项规模极大时依然卡顿与占内存。
时间分片
- 原理:利用
requestAnimationFrame将大量渲染任务拆分到帧之间执行,由浏览器调度。 - 优点:避免长任务阻塞,首屏可更快出现部分内容。
- 缺点:最终仍是全量 DOM,渲染次数多且不直观;复杂场景效果有限。
虚拟列表
- 原理:只创建“可视区域”内的少量列表项,随滚动替换为下一段数据。滚动的高度由一个“占位容器”提供。
- 优点:DOM 数量与视口大小线性相关,性能稳定、内存友好。
- 适用:聊天记录、日志、排名榜、海量表格等长列表场景。
实践建议
- 海量列表优先采用虚拟列表;配合懒加载图片与时间分片初始化可进一步优化体验。



原理与数据模型
- 基本变量:
screenHeight:可视区域高度itemSize:每项固定高度(定高场景)listData:完整数据源(数组)scrollTop:滚动偏移
- 推导数据:
listHeight = listData.length * itemSizevisibleCount = Math.ceil(screenHeight / itemSize)startIndex = Math.floor(scrollTop / itemSize)endIndex = Math.min(startIndex + visibleCount, listData.length)visibleData = listData.slice(startIndex, endIndex)startOffset = scrollTop - (scrollTop % itemSize)(渲染位移与项高度对齐)
为什么对齐位移
对齐到 itemSize 边界可以避免出现“半个列表项”露出,确保每次渲染的首项顶部与可视区边界一致,视觉更稳定。
定高实现示例
虚拟列表组件
vue
<template>
<!-- 虚拟列表组件外层容器 -->
<div
ref="listContainer"
class="infinite-list-container"
@scroll="scrollHandler"
>
<!-- 该元素高度为总列表的高度,目的是形成滚动条 -->
<div
class="infinite-list-phantom"
:style="{ height: listHeight + 'px' }"
></div>
<!-- 该元素为可视区域,里面存放当前可见的列表项 -->
<div
class="infinite-list"
:style="{ transform: `translateY(${startOffset.value}px)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
class="infinite-list-item"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
listData: {
type: Array,
default: () => [],
required: true,
},
itemSize: {
type: Number,
default: 150,
required: true,
},
});
// 可视区域的高度
const listContainer = ref(null); // 容器引用
const screenHeight = ref(0); // 可视区域的高度
onMounted(() => {
screenHeight.value = listContainer.value.clientHeight;
});
// 可显示的列表项数 = 可视区域高度 / 列表项高度
const visibleCount = computed(() => {
return Math.ceil(screenHeight.value / props.itemSize);
});
// 显示列表项的起始索引和结束索引[startIndex, endIndex)
const startIndex = ref(0);
const endIndex = computed(() => {
// 结束索引不能超过列表项数量
return Math.min(startIndex.value + visibleCount.value, props.listData.length);
});
// 列表显示数据
const visibleData = computed(() => {
return props.listData.slice(startIndex.value, endIndex.value);
});
// 列表总高度 = 列表项数量 * 列表项高度
const listHeight = computed(() => {
return props.listData.length * props.itemSize;
});
// 向下位移的距离
const startOffset = ref(0);
// 滚动事件处理函数
const scrollHandler = () => {
const scrollTop = listContainer.value.scrollTop;
// 更新各项数据
startIndex.value = Math.floor(scrollTop / props.itemSize);
// 向下位移的距离
startOffset.value = scrollTop - (scrollTop % props.itemSize); // 调整为与itemSize对齐
};
</script>
<style scoped>
.infinite-list-container {
height: 100%;
position: relative;
/* 形成滚动条,绝对定位的子元素虽然不参与父元素的高度计算,但是会影响到父元素的滚动条 */
overflow: auto;
}
.infinite-list-phantom {
position: absolute;
top: 0; /* 是相对于“滚动内容区域”的顶部 */
left: 0;
right: 0;
z-index: -1;
}
.infinite-list {
position: absolute;
top: 0; /* 是相对于“滚动内容区域”的顶部 */
left: 0;
right: 0;
text-align: center;
background-color: #ccc;
}
.infinite-list-item {
padding: 10px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
}
</style>使用示例
vue
<template>
<div class="app">
<!-- 接受列表数组和数据项高度的Props -->
<VirtualList :listData="listData" :itemSize="100" />
</div>
</template>
<script setup>
import { ref } from "vue";
import VirtualList from "./components/VirtualList.vue";
const listData = ref(
Array.from({ length: 10000 }, (_, index) => {
return {
id: index + 1,
value: `第${index + 1}条数据`,
};
})
);
</script>
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#app,
.app {
height: 100%;
}
</style>结构说明
phantom负责滚动高度;list负责实际渲染。transform: translateY(startOffset)将渲染内容对齐到可视区。
进一步优化与问题处理
动态高度(非定高)
- 问题:列表项高度不一致时,无法用简单除法得到
startIndex。 - 思路:实时或缓存项高度,维护“前缀高度和”(累计高度),用“二分查找”定位
scrollTop对应的首项索引。
vue
<script setup>
import { ref, reactive, computed } from "vue";
const props = defineProps({
listData: Array,
estimatedSize: { type: Number, default: 80 },
overscan: { type: Number, default: 2 },
});
const listContainer = ref(null);
// 记录每项真实高度,未测量时用估算值
const heights = reactive(new Map());
const prefix = ref([0]); // 前缀和数组(第 i 项表示 0..i-1 的累计高度)
const rebuildPrefix = () => {
const arr = [0];
for (let i = 0; i < props.listData.length; i++) {
const h = heights.get(i) ?? props.estimatedSize;
arr[i + 1] = arr[i] + h;
}
prefix.value = arr;
};
const totalHeight = computed(() => {
// 懒更新:当测量发生时调用 rebuildPrefix
return prefix.value[prefix.value.length - 1];
});
const findStartIndex = (scrollTop) => {
// 二分查找 prefix 中第一个大于等于 scrollTop 的位置 - 1
let lo = 0,
hi = prefix.value.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (prefix.value[mid] <= scrollTop) lo = mid + 1;
else hi = mid;
}
return Math.max(0, lo - 1);
};
// 组件内的滚动与测量逻辑略,核心在于:测量可见项高度 => 更新 heights => rebuildPrefix => 重新计算索引与位移
</script>动态高度实现提示
- 尽量在首次渲染后测量可见项,并在滚动过程中渐进式完善高度缓存。
- 可选用
ResizeObserver监听项高度变化,减少手动测量复杂度。
白屏与缓冲(Overscan)
- 现象:快速滚动时,渲染窗口未及时更新导致短暂空白。
- 方案:增加
overscan(可视项之外的额外缓冲数量),并用requestAnimationFrame或节流优化滚动处理。
js
const onScroll = (e) => {
const scrollTop = e.target.scrollTop;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
startIndex.value = Math.floor(scrollTop / props.itemSize);
startOffset.value = scrollTop - (scrollTop % props.itemSize);
scheduled = false;
});
}
};滚动性能优化
- 使用
passive监听提升滚动响应:addEventListener('scroll', handler, { passive: true })。 - 使用
requestAnimationFrame或节流(如 16–33ms)降低计算频率。 - 减少不必要的派生计算,用
computed处理纯派生数据。
工程化与生态选择
- 开源库:
vue-virtual-scroller、@tanstack/virtual-core等,覆盖定高与动态高度,提供更丰富的特性(锚点、分组、表格)。 - 团队实践:在可控复杂度下优先自研定高版本;复杂需求采用成熟库减少维护成本。
常见坑与注意事项
常见错误
- key 不稳定:
v-for的:key必须稳定唯一,否则会出现错位与复用错误。 - 容器高度未设置:可视容器未给定明确高度会导致滚动与计算异常。
- 过度 reflow:频繁测量与同步布局读写会触发回流,需批量或节流处理。
- SEO 与可访问性:虚拟列表仅渲染部分内容,服务端渲染与爬虫场景需评估;确保可聚焦与导航无障碍。
总结
- 虚拟列表通过“占位 + 视窗渲染 + 位移对齐”将 DOM 数量与视口绑定,显著提升海量数据场景的渲染与交互性能。
- 定高实现简单可靠;动态高度需配合测量与前缀和 + 二分查找定位。
- 配合
overscan与滚动节流可进一步消除白屏与抖动,达到专业级交互体验。
