解决大数据渲染卡顿:Vue3 虚拟列表组件的完整实现方案
文章简介
本文介绍的是 vue3 中虚表组件的实现方式。当需要展示的数据量达到几百上千条时就需要使用虚表,否则大量组件的渲染会导致页面卡顿甚至卡死。 备注:本文介绍的虚表只支持固定且高度相同的数据元素。
实现原理
滚动容器
┌─────────────────────────────┐
│ │
│ ~~~~~~~~~~~~~~~~~~~ │
│ ~ 未渲染的虚拟行 ~ │
│ ~~~~~~~~~~~~~~~~~~~ │
│ ┌─────────────────────┐ │
│ │ 实际渲染区域 │ │
│ │ (visibleRows) │ │
│ └─────────────────────┘ │
│ ~~~~~~~~~~~~~~~~~~~ │
│ ~ 未渲染的虚拟行 ~ │
│ ~~~~~~~~~~~~~~~~~~~ │
│ │
└─────────────────────────────┘
- 虚表由 3 个元素组成,分别为有固定高度的根元素(滚动容器)提供数据滚动能力、用于撑开根容器的占位元素、用于展示信息的区域渲染元素。
- 渲染区域在根元素内部使用绝对定位 position: absolute; 脱离文档流。
- 实时计算需要渲染的元素行。
- 备注:当需要渲染的元素发生变化时,通过 transform: translateY(100px); 属性对渲染区域进行偏移,确保渲染连续。
- 根元素使用相对定位 position: relative; 使渲染元素在根元素内部定位、滚动。
- 占位元素只用来撑开根元素内部空间,让根元素提供滚动能力。
- 备注:占位元素的高度计算方式:数据量 * 数据展示元素高度。
外部属性定义
- items: 使用虚表的父组件传入的所有要展示数据源。
- itemHeight:每个数据元素的展示行高
- width、height:可由父组件传入固定数值,默认撑满父组件。
- space:展示元素之间的间距
- bufferSize:渲染区域上下缓冲区大小
const props = defineProps({
// 数据源 (必须)
items: {
type: Array,
required: true,
},
// 行高 (必须,单位px)
itemHeight: {
type: Number,
required: true,
},
// 容器宽度 (可选,未指定则撑满父元素)
width: {
type: [String, Number],
default: "100%",
},
// 容器高度 (可选,未指定则撑满父元素)
height: {
type: [String, Number],
default: "100%",
},
// item 间距 (可选,默认5px)
space: {
type: Number,
default: 8,
},
// 上下缓冲区行数,避免快速滚动白屏 (默认5)
bufferSize: {
type: Number,
default: 5,
},
});
插槽定义
主要用于定义数据展示元素插槽的数据类型,否则使用虚表的父组件在定义数据展示元素时会飘红
defineSlots<{
item(props: { item: any; index: number }): void;
}>();
html 部分
- viewportRef 绑定根元素对象,用于获取实际视口高度,视口高度会用来计算可展示元素数量
- containerStyle: 用于设置父组件传递的根容器宽高,或设置默认值
- virtual-viewport:根元素 css 属性
- virtual-phantom:占位块 css 属性
- totalHeight:虚表需要展示的总数据占位高度
- virtual-content:渲染区 css 属性
- offsetY:渲染区偏移量
- visibleRows:实际渲染元素
- itemHeight:插槽定义的数据展示元素高度,由使用虚表的父组件通过属性传入
- itemActualHeight:渲染元素实际高度 = 插槽定义的数据展示元素高度(itemHeight) + 元素间隔(space)
<template>
<!-- 虚拟滚动视口 -->
<div
ref="viewportRef"
class="virtual-viewport"
:style="containerStyle"
@scroll="onScroll"
>
<!-- 占位块,撑开滚动空间 -->
<div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />
<!-- 渲染内容区,绝对定位跟随滚动 -->
<div
class="virtual-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="row in visibleRows"
:key="row.index"
:style="{ height: itemActualHeight + 'px' }"
>
<!-- 通过插槽让外部自定义每一项的渲染内容 -->
<slot
name="item"
:item="row.data"
:index="row.index"
:style="{ height: itemHeight + 'px' }"
>
<!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
<div>
<span>#{{ row.index + 1 }}</span>
<span>{{ row.data }}</span>
</div>
</slot>
</div>
</div>
</div>
</template>
css 部分
<style scoped>
.virtual-viewport {
position: relative;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.virtual-phantom {
width: 100%;
pointer-events: none;
}
.virtual-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
}
</style>
实时计算变量实现
核心逻辑
- 实时计算父组件设置的根元素宽高
- 虚表组件挂载后获得视口高度,并订阅根元素的大小变化。
- 监听展示数据的变化,超出滚动范围时修正滚动范围。
- 实时计算每项元素实际高度
- 实时计算占位元素总高度
- 实时计算起始结束索引
- 实时计算实际渲染的数据行
- 实时计算偏移量
- 实时计算使用 vue3 的 computed() 方法
// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
(e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
scrollTop.value = e.target.scrollTop;
emit("scroll", scrollTop.value);
};
// 容器样式
const containerStyle = computed(() => {
const style: any = {};
style.width =
typeof props.width === "number" ? `${props.width}px` : props.width;
style.height =
typeof props.height === "number" ? `${props.height}px` : props.height;
return style;
});
// 更新容器高度
const updateViewportHeight = () => {
if (viewportRef.value) {
const height = viewportRef.value.clientHeight;
if (viewportHeight.value !== height) {
viewportHeight.value = height;
}
}
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
// 初始化容器高度
updateViewportHeight();
// 监听容器大小变化
resizeObserver = new ResizeObserver(() => {
updateViewportHeight();
});
if (viewportRef.value) {
resizeObserver.observe(viewportRef.value);
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
// 监听数据变化,确保滚动位置有效
watch(
() => props.items,
() => {
// 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
nextTick(() => {
if (viewportRef.value) {
const maxScroll = Math.max(
0,
totalHeight.value - viewportRef.value.clientHeight,
);
if (viewportRef.value.scrollTop > maxScroll) {
viewportRef.value.scrollTop = maxScroll;
}
}
});
},
);
// 每项实际高度
const itemActualHeight = computed(() => {
return props.itemHeight + props.space;
});
// 计算总数据量
const totalItems = computed(() => props.items.length);
// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);
// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
return Math.max(0, rawStart - props.bufferSize);
});
// 计算结束索引
const endIndex = computed(() => {
const rawEnd = Math.ceil(
(scrollTop.value + viewportHeight.value) / itemActualHeight.value,
);
return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});
// 实际需要渲染的行数据
const visibleRows = computed(() => {
const start = startIndex.value;
const end = endIndex.value;
return props.items
.map((item, index) => ({
index: start + index,
data: item,
}))
.slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);
对外暴露滚动事件、滚动距离
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
(e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
scrollTop.value = e.target.scrollTop;
emit("scroll", scrollTop.value);
};
对外暴露根容器
defineExpose({
$el: viewportRef,
});
使用虚表的父组件可以通过 ref 绑定虚表的根元素。
假设父组件通过 ref="parent" 绑定虚表根元素,通过父组件控制虚表滚动的方法为
parent.value.$el.scrollTop = 100;
父组件使用
<virtual-table
ref="parent"
:items="data"
:space="8"
:itemHeight="150"
@scroll="(value: number) => (scrollTop = value)"
>
<template #item="{ item, index }">
<div>序号:{{ index }}</div>
<div>内容:{{ item }}</div>
</template>
</virtual-table>
附源码
<script setup lang="ts">
import {
ref,
computed,
onMounted,
onBeforeUnmount,
watch,
nextTick,
} from "vue";
const props = defineProps({
// 数据源 (必须)
items: {
type: Array,
required: true,
},
// 行高 (必须,单位px)
itemHeight: {
type: Number,
required: true,
},
// 容器宽度 (可选,未指定则撑满父元素)
width: {
type: [String, Number],
default: "100%",
},
// 容器高度 (可选,未指定则撑满父元素)
height: {
type: [String, Number],
default: "100%",
},
// item 间距 (可选,默认8px)
space: {
type: Number,
default: 8,
},
// 上下缓冲区行数,避免快速滚动白屏 (默认5)
bufferSize: {
type: Number,
default: 5,
},
});
defineSlots<{
item(props: { item: any; index: number }): void;
}>();
// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
(e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
scrollTop.value = e.target.scrollTop;
emit("scroll", scrollTop.value);
};
// 容器样式
const containerStyle = computed(() => {
const style: any = {};
style.width =
typeof props.width === "number" ? `${props.width}px` : props.width;
style.height =
typeof props.height === "number" ? `${props.height}px` : props.height;
return style;
});
// 更新容器高度
const updateViewportHeight = () => {
if (viewportRef.value) {
const height = viewportRef.value.clientHeight;
if (viewportHeight.value !== height) {
viewportHeight.value = height;
}
}
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
// 初始化容器高度
updateViewportHeight();
// 监听容器大小变化
resizeObserver = new ResizeObserver(() => {
updateViewportHeight();
});
if (viewportRef.value) {
resizeObserver.observe(viewportRef.value);
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
// 监听数据变化,确保滚动位置有效
watch(
() => props.items,
() => {
// 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
nextTick(() => {
if (viewportRef.value) {
const maxScroll = Math.max(
0,
totalHeight.value - viewportRef.value.clientHeight,
);
if (viewportRef.value.scrollTop > maxScroll) {
viewportRef.value.scrollTop = maxScroll;
}
}
});
},
);
// 每项实际高度
const itemActualHeight = computed(() => {
return props.itemHeight + props.space;
});
// 计算总数据量
const totalItems = computed(() => props.items.length);
// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);
// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
return Math.max(0, rawStart - props.bufferSize);
});
// 计算结束索引
const endIndex = computed(() => {
const rawEnd = Math.ceil(
(scrollTop.value + viewportHeight.value) / itemActualHeight.value,
);
return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});
// 实际需要渲染的行数据
const visibleRows = computed(() => {
const start = startIndex.value;
const end = endIndex.value;
return props.items
.map((item, index) => ({
index: start + index,
data: item,
}))
.slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);
defineExpose({
$el: viewportRef,
});
</script>
<template>
<!-- 虚拟滚动视口 -->
<div
ref="viewportRef"
class="virtual-viewport"
:style="containerStyle"
@scroll="onScroll"
>
<!-- 占位块,撑开滚动空间 -->
<div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />
<!-- 渲染内容区,绝对定位跟随滚动 -->
<div
class="virtual-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="row in visibleRows"
:key="row.index"
:style="{ height: itemActualHeight + 'px' }"
>
<!-- 通过插槽让外部自定义每一项的渲染内容 -->
<slot
name="item"
:item="row.data"
:index="row.index"
:style="{ height: itemHeight + 'px' }"
>
<!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
<div>
<span>#{{ row.index + 1 }}</span>
<span>{{ row.data }}</span>
</div>
</slot>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-viewport {
position: relative;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.virtual-phantom {
width: 100%;
pointer-events: none;
}
.virtual-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
}
</style>