基于ant design vue table组件实现虚拟滚动
本文将对虚拟滚动进行简要介绍,并基于ant design vue table组件,为其添加上虚拟滚动的功能。笔者所使用的3.x版本并未提供此虚拟滚动功能,官网其实提供了一个实现方案,但是收费......,于是有了这篇文章。才疏学浅,文章如有不妥之处,烦请指出~
1、虚拟滚动
讲明白一个知识点的要素主要有三个:是什么,为什么,怎么做,下面从这三个角度出发,简单扼要说明虚拟滚动。
不深入细节,更详细的内容后续另起一篇文章
是什么
虚拟滚动(虚拟列表),是一种技术实现方案。针对需要展示大数据量的表格进行渲染优化。假设有10w数据需要展示在页面上,一次性全部进行展示显然会让页面直接卡爆。
可能会有很多种方式解决:分页,懒加载,时间分片都可来进行优化渲染,但是他们各有各的不足之处
分页:简单粗暴,每次只展示固定数量,想要浏览更多,只能切换到下一页
懒加载:此场景下可以假设是触底加载更多。页面初次仅展示20条,触底时再新增一些数据进行渲染。(随着触发次数的增加,列表元素的DOM也在增加,最终也会造成页面卡顿)
时间分片:与懒加载略有不同,规定每次页面每次渲染一定个数,渲染完毕之后再渲染下一批,直到全部完成渲染。(弊端与懒加载类似,并且每次页面渲染都可能会造成闪动)
以上方案共有的问题
懒加载、时间分片都不可避免地可能在列表中渲染了大量的DOM,如果每一列表项都是简单的渲染还好,但凡每一项内容量复杂,都会造成性能问题。
虚拟列表就很好地回避了这个问题,通过计算每次仅仅渲染可视区域的内容。
简单示例
<template>
<div class="container" :style="{ height: `${virtualList.listHeight}px` }">
<!-- 占位元素 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 渲染区域 -->
<div class="content" :style="{ transform: `translate(0, ${virtualList.currentOffset}px)` }">
<div
v-for="item in showData"
:key="item.id"
:style="{ height: `${virtualList.itemHeight}px` }"
class="list-item"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
const data = ref([]);
const virtualList = reactive({
itemHeight: 50, // 每一项高度, 固定高度
listHeight: 500, // 表格总高度,自定义
currentOffset: 0, // 偏移量
start: 0,
end: 10,
count: 10,
});
?
onMounted(() => {
// 获取数据
data.value = mockData();
// 初始化虚拟列表
init();
});
?
const init = () => {
const containerDom = document.querySelector(".container");
virtualList.end = virtualList.start + virtualList.count;
containerDom && containerDom.addEventListener("scroll", handleScroll);
};
?
// 总数据所占据的高度
const totalHeight = computed(() => data.value.length * virtualList.itemHeight);
// 动态展示的数据
const showData = computed(() => data.value.slice(virtualList.start, virtualList.end));
// 随着滚动动态计算start、end、currentOffset
const handleScroll = (e) => {
const scrollTop = e.target.scrollTop;
virtualList.start = Math.ceil(scrollTop / virtualList.itemHeight);
virtualList.end = virtualList.start + virtualList.count;
virtualList.currentOffset = scrollTop - (scrollTop % virtualList.itemHeight);
};
// 模拟数据
const mockData = (count = 10000) => {
let dataList = [];
for (let i = 1; i <= count; i++) {
dataList.push({ id: i, value: "字符内容" + i });
}
return dataList;
};
</script>
?
<style scoped>
.container {
position: relative;
overflow-y: auto;
}
.content {
position: absolute;
top: 0;
right: 0;
left: 0;
text-align: center;
}
.list-item {
border: 1px solid #999;
}
</style>
以上代码实现了一个固定宽度的虚拟列表:
- 利用phantom撑起实际渲染所有数据的高度,content仅渲染可视区域内容count条数据
- 通过scroll事件不断计算content的位置以及对应位置应当展示的数据
- 1、scrollTop来确定开启了绝对定位的content的向下偏移量,确保它始终在视口
- 2、偏移量/每一项高度 = 开始位置start,计算得end。于是得出content应该展示什么数据
每一次的滚动,showData的值在不断地变化,从DOM结构上我们看到不论怎样滚动都只渲染10条数据。所以,无论数据量多少,都不会对其渲染性能产生什么影响
注意:以上仅仅只是十分简单业务场景实现,更加通用的虚拟滚动可能更加复杂,例如不固定高度的滚动,缓冲区,滚动事件的防抖节流,将虚拟滚动抽离为一个即用即生效的通用方法等等。本文章不作过多介绍。
2、结合antd Vue table
0.0 直接看代码
<template>
<div class="page">
<a-table
class="v-table"
:dataSource="showData"
:columns="columns"
:pagination="false"
:scroll="{ y: 500 }"
></a-table>
</div>
</template>
?
<script setup>
const vList = ref({
itemHeight: 55,
start: 0,
end: 10,
count: 10,
container: null,
content: null,
offset: 0,
});
const data = ref([]);
const showData = computed(() => data.value.slice(vList.value.start, vList.value.end));
?
const init = () => {
const container = document.querySelector(`.v-table .ant-table-body`);
const content = document.querySelector(`.v-table .ant-table-body table`);
vList.value.container = container;
vList.value.content = content;
if (!container || !content) return;
// 创建占位元素、添加进container、并支撑起高度
let isExist = document.querySelector(`.v-table .phantom`);
if (isExist) container.removeChild(isExist);
const phantom = document.createElement("div");
phantom.className = "phantom";
container.appendChild(phantom);
phantom.style.height = data.value.length * vList.value.itemHeight + "px";
?
// 注册滚动事件
container.addEventListener("scroll", scrollFn);
};
const scrollFn = () => {
const scrollTop = vList.value.container.scrollTop;
vList.value.start = Math.ceil(scrollTop / vList.value.itemHeight);
vList.value.end = vList.value.start + vList.value.count;
vList.value.offset = scrollTop - (scrollTop % vList.value.itemHeight);
vList.value.content.style.transform = `translateY(${vList.value.offset}px)`;
};
?
onMounted(() => {
init();
});
?
// ====================== 其他内容 ========================
?
const columns = ref([
{
title: "姓名",
dataIndex: "name",
key: "name",
},
{
title: "年龄",
dataIndex: "age",
key: "age",
},
]);
const mockData = (count = 1000) => {
let newData = [];
for (let i = 0; i < count; i++) {
newData.push({
key: i,
name: "胡彦祖" + i,
age: i,
});
}
data.value = newData;
};
mockData();
</script>
?
<style lang="less" scoped>
.page {
:deep(.ant-table) {
// container
.ant-table-body {
position: relative;
left: 0;
top: 0;
table {
position: absolute;
}
}
}
}
</style>
将虚拟滚动结合进ant design vue的table组件的方式跟简单的虚拟滚动步骤基本一致。主要是能够获取到正确的container节点与其content节点
当然,如果每个表格我们都如此操作会有些麻烦,最好的做法是将其抽离为一个通用的方法
3、方法封装
期待的效果是这样的
<template>
<a-table :dataSource="showData" :columns="columns" />
</template>
?
<script setup>
?
const data = ref([]) //数据源
const virtualList = ref([]) //虚拟列表信息
virtualList.value = useVirtualList(data.value.length, config); //应用虚拟滚动
const showData = computed(() => data.value.slice(virtualList.value.start, virtualList.value.end)) //展示数据
?
</script>
简单讲就是通过useVirtualList对a-table进行初始化,利用virtualList响应式变量存储不断变化的start、end等信息,最终实现效果
那么初始化的config应该传入以下数据
{
start: Number, // 默认为0
end: Number, // end - srart = count
itemHeight: Number, //每一项的高度
className: String,//最好传入,避免页面存在多个table获取到错误目标table
tableHeight: Number,// 容器总高度(允许动态变化,传入ref对象)
}
那么封装代码如下
export const useVirtualList = (dataLen, config) => {
let { start = 0, end, itemHeight, className, tableHeight } = config || {};
?
const virtualList = reactive({
start,
end,
itemHeight,
tableHeight,
className,
currentOffset: 0,
wrapperDom: null,
contentDom: null,
});
// 计算 end count className
const isDynamicHeight = isRef(tableHeight); //是否传入的表格高度是动态的
virtualList.end = end || parseInt((isDynamicHeight ? tableHeight.value : tableHeight) / itemHeight);
virtualList.count = virtualList.end - start;
className = `${virtualList.className ? "." + virtualList.className + " " : ""}`;
?
const init = () => {
// 获取container和content元素
virtualList.wrapperDom = document.querySelector(`${className}.ant-table-body`);
virtualList.contentDom = document.querySelector(`${className}.ant-table-body table`);
if (!virtualList.wrapperDom || !virtualList.contentDom) return;
?
// 样式调整
virtualList.wrapperDom.style.position = "relative";
virtualList.wrapperDom.style.top = virtualList.wrapperDom.style.left = "0";
virtualList.contentDom.style.position = "absolute";
virtualList.wrapperDom.addEventListener("scroll", handleScroll);
?
// 创建占位元素,撑起高度
let isExist = document.querySelector(`${className}.palceholder-dom`);
if (isExist) virtualList.wrapperDom?.removeChild(isExist);
const placeHolderDom = document.createElement("div");
placeHolderDom.className = "palceholder-dom";
virtualList.wrapperDom.appendChild(placeHolderDom);
placeHolderDom.style.height = dataLen * virtualList.itemHeight + "px";
};
?
const handleScroll = () => {
// 获取偏移量
const scrollTop = virtualList.wrapperDom.scrollTop;
// 重新计算start end
virtualList.start = Math.ceil(scrollTop / virtualList.itemHeight);
virtualList.end = virtualList.start + virtualList.count;
// contentDom元素进行偏移,保证视觉可见
virtualList.currentOffset = scrollTop - (scrollTop % virtualList.itemHeight);
virtualList.contentDom.style.transform = `translateY(${virtualList.currentOffset}px)`;
};
?
nextTick(() => {
init();
});
return virtualList;
}; virtualList.resetTable = resetTable;
?
return virtualList;
};
应用如下
目前的仅封装实现了功能,笔者在实际使用中还拓展了序号列,增加了缓冲区,防抖优化,一定数据量后启用,与表格还原等功能,这里不作展开。
<template>
<a-table :dataSource="showData" :columns="columns" class="v-table" :scroll="{ y: 500 }" :pagination="false" />
</template>
?
<script setup>
import { useVirtualList } from "@/hooks/useVirtualList"
?
const data = ref([]) //数据源
const virtualList = ref([]) //虚拟列表信息
watch(data, () => {
// 确保data有值
virtualList.value = useVirtualList(data.value.length, {
end: 10,
className: 'v-table',
itemHeight: 55,
});
})
const showData = computed(() => data.value.slice(virtualList.value.start, virtualList.value.end))
?
?
const columns = ref([
{
title: "姓名",
dataIndex: "name",
key: "name",
},
{
title: "年龄",
dataIndex: "age",
key: "age",
},
]);
const mockData = (count = 1000) => {
let newData = [];
for (let i = 0; i < count; i++) {
newData.push({
key: i,
name: "胡彦祖" + i,
age: i,
});
}
data.value = newData;
};
mockData();
</script>
?
<style lang="less" scoped></style>
最后,该虚拟滚动存在诸多优化空间,限于文章篇幅与个人能力,就不献丑了。笔者也实现过一个动态高度的版本,但是性能实在不高,后续优化后再作分享。