背景
今年两次用到虚拟列表技术,一种是二维平面因为实际现场要求支持1000 * 1000的百万节点展示,另一种是一维垂直列表最大每年有万条数据。
二维虚拟列表
如下图,实际场地上有横纵坐标都是1到1000,我们忽略每个坐标方格上样式和交互,显然直接渲染百万个小格子导致页面卡死,只有根据横纵滚动条的滚动动态渲染可视区内小格子。
特点:
- 小格子长宽固定
一维垂直虚拟列表
如下图,列表项内容高度不固定,每个红格子是一项。
三种技术特点
虚拟列表常见三种实现技术:
1、第一种最常见是只渲染可视化区域内容,结合滚动条的监听滚动事件动态计算要显示的节点,并计算各节点position:absolute绝对定位。缺点是复杂性很高(要实现滚动监听和节点定位),scroll 事件密集发生,计算量很大,容易造成性能问题。另外如果行高不固定(实际业务中往往需要这样), 那计算将会更加复杂。
参考 juejin.cn/post/723285…
2、第二种是用 IntersectionObserver 监听DOM与文档视窗的交叉状态,缺点是事先需渲染全部元素才能监听每个元素是否进入可视区域中,就先渲染一个div占位,等到触发callback了再替换成实际的内容。监听的是占位DOM,渲染占位DOM和渲染实际内容相比开销小到忽略不计,但也需要渲染所有div数量
。参考 juejin.cn/post/715860…
3、第三种是用css新属性 content-visibility,属性控制一个元素是否渲染其内容,它允许用户代理(浏览器)潜在地省略大量布局和渲染工作,直到需要它为止。缺点类似上面DIV占位,实际是不渲染子节点的内容,但是子节点框还是会渲染,所有子节点的DIV框框还是会实际生成
参考 blog.csdn.net/qq_41581588…
上面三种方法各有优缺点,我还是倾向于第一种达到真正的只渲染小量DOM结构。
后面我们都说第一种要实现虚拟列表,我认为要解决这些问题:
1、滚动条长度计算:正常情况下由于内部高度大于父容器高度,所以才有滚动条出现。但是虚拟列表是高度实际上几乎等于父容器高度,所以滚动条长度要模拟出渲染所有节点撑开父容器的高度。
2、子节点内容高度不固定:如果是固定高度能容易计算出滚动条高度是所有子项数量 * 单个高度,子项绝对定位也方便;但是如果子项高度不固定,则需要动态根据实际渲染子项后再次动态计算。
实现思路
// 最外层容器
<div ref="layoutContainerRef" class="layout-container" @scroll="handleScroll">
// 占位层
<div class="layout-mark" :style="layoutMarkSize"/>
// 布局层
<div class="layout-room">
// 渲染子项包裹组件,为了获取自定义组件的高度
<visual-list-item>
// 自定义内容组件
<my-self-item v-for="item in visualRecords" :item="item"/>
</visual-list-item>
</div>
</div>
.layout-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
.layout-mark {
position: absolute;
z-index: -1;
top: 0;
left: 0;
right: 0;
}
.layout-room {
position: absolute;
top: 0;
left: 0;
right: 0;
}
第一步画外部容器宽高
这是最外层容器 设置宽高像素,设置可滚动,设置相对定位属性,后面都根据这一层来绝对定位
第二步计算占位层高度和绝对定位
占位层的作用是撑开外部容器,使得出现滚动条,难点是怎么计算占位层高度。
// 初始化复制所有子项高度
// 预设高度 100
// __height 每个子项高度
// __top 每个子项相对于占位层的top距离
// __bottom 每个子项相对于占位层的top距离
const allRecords = computed(() => {
let top = 0;
props.records.forEach((item) => {
item.__height = item.__height || 100;
item.__top = top;
top = item.__top + item.__height;
item.__bottom = top;
});
return props.records;
});
// 占位层宽高
const layoutContainerRef = ref();
const layoutMarkSize = computed(() => {
const lastItem = allRecords.value.length > 0 ? allRecords.value[allRecords.value.length - 1] : null;
// 最后一个子项底部距离即可
return {
height: (lastItem ? lastItem.__bottom : 0) + 'px',
};
});
情况一:每个子项高度固定
如果是内部子项高度是固定的比如100px,那用 100 * 子项数量就可以设置占位层高度
情况二:子项高度不固定
子项高度不固定,则需要首先预设每项高度,再根据实际渲染子项之后重新计算占位层高度,这种情况是兼容情况一的
这里需要增加 渲染子项包裹组件 VisualListItem
- VisualListItem 组件,给用户自定义组件获取到
__height
属性,
<template>
<div ref="itemRef">
<slot :item="item"></slot>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
const props = defineProps({
item: {
type: Object,
required: true,
},
});
const itemRef = ref();
onMounted(() => {
props.item.__height = itemRef.value.clientHeight;
});
</script>
这样经过 VisualListItem
的 onMounted 之后,实际高度就会生成,在外面会重新计算 layoutMarkSize
属性,就重新修改了占位层的高度
第三步监听外部层滚动事件
原理是通过监听滚动,计算出滚动top值,计算出应该显示哪些子项,将子项包裹层用transform偏移到可视化显示区,用translate3D是性能优化,交给GPU渲染
// 可视层位置
const layoutRoomPositionStyle = ref({
minRow: 0,
maxRow: 0,
transform: `translate3D(0px,0px,0)`,
});
// 可见数据,后面在滚动事件中修改 minRow和maxRow值,就更新了要显示的数据项是哪些
const visualRecords = computed(() => allRecords.value.slice(layoutRoomPositionStyle.value.minRow, layoutRoomPositionStyle.value.maxRow + 1));
// 滚动事件
function handleScroll(scrollEvent) {
const { scrollTop } = scrollEvent.target;
// 计算显示隐藏的cell的横纵坐标
const { minRow } = layoutRoomPositionStyle.value;
// 格子尺寸
const { height } = props;
// 最小可视化行、列
layoutRoomPositionStyle.value.minRow = allRecords.value.findIndex((item) => item.__top <= scrollTop && item.__bottom >= scrollTop);
// 顶部多渲染4条
layoutRoomPositionStyle.value.minRow = Math.max(0, layoutRoomPositionStyle.value.minRow - 4);
// 最大可视化行、列
const tempMaxRow = layoutRoomPositionStyle.value.minRow + Math.floor(scrollEvent.target.clientHeight / height);
layoutRoomPositionStyle.value.maxRow = allRecords.value.findIndex(
(item) => item.__top <= scrollTop + scrollEvent.target.clientHeight && item.__bottom >= scrollTop + scrollEvent.target.clientHeight
);
// 底部多渲染4条
layoutRoomPositionStyle.value.maxRow = Math.max(tempMaxRow, layoutRoomPositionStyle.value.maxRow) + 4;
if (allRecords.value.length > 0) {
layoutRoomPositionStyle.value.maxRow = Math.min(allRecords.value.length, layoutRoomPositionStyle.value.maxRow);
}
// 可视化层偏移量
if (minRow != layoutRoomPositionStyle.value.minRow) {
layoutRoomPositionStyle.value.transform = `translate3D(0,${allRecords.value[layoutRoomPositionStyle.value.minRow].__top}px,0)`;
}
}
到此就基本做完了,但是要优化一下,第一次进来页面的时候,没有计算可视化区域应该渲染多少条,所以第一次页面渲染的时候,要先执行以下 handleScroll 方法,在 onMounted里面加上
onMounted(() => {
handleScroll({ target: layoutContainerRef.value });
});
一维垂直虚拟列表动态内容高度变化组件就支持好了,二维平面的看源码 VisualGridList 组件,基本类似