前言
最近在小程序的工作中有许多场景是大数据量的列表渲染,这种渲染场景如果不对它进行优化会非常耗性能,常见的优化手段有:分片渲染与虚拟列表,恰好Taro官方也有提供虚拟列表组件,但有同事反馈这个组件并不好用,体验也不好,白屏非常明显。
在虚拟列表中滚动过快造成的白屏其实是必然的,关键在于我们怎么去优化它,把白屏比例尽量降到最低,这其中还得权衡性能与体验,想要白屏越少,那么你要渲染的节点就越多,性能自然也就越差。反之,你想要性能好,那么白屏就会增加。
接下来,自己动手实现一个虚拟列表,支持以下功能:
- 元素定高,可支持滚动到指定元素位置
- 元素不定高,无需关心子元素的高度,组件内自动计算
- 可支持一次性加载所有数据,也支持分页加载数据
原理介绍
虚拟列表的原理其实就是:只渲染可视区内的元素,对于非可视区的元素不进行渲染,这样就可以提高长列表的渲染性能。
但是为了在滚动过程尽可能的降低白屏率,我们可以多渲染几屏元素
图片
如上图,原理大概是这样:
- 将数据处理成每一屏一个渲染单位,用二维数组存储
- 使用Taro.createIntersectionObserver监听每一屏内容是否在可视区,在可视区直接渲染,不在可视区则使用该屏真实高度进行占位。高度怎么来?可以定高,也可以不定高,不定高的话就是等每一屏渲染完成后记录渲染高度即可,两种都有实现,具体可以看下面的实现方法
这样的话,每次真实渲染的节点数就远小于列表全部渲染的节点数,可以极大地提高页面渲染性能。
实现
处理数据
首先将外部的数据处理成二维数组,方便后续按屏渲染
// 处理列表数据,按规则分割
let initList: any[] = []; // 初始列表(备用)
const dealList = (list: any[]) => {
const segmentNum = props?.segmentNum; // 每页显示数量
let arr: any[] = [];
const _list: any[] = [];
list.forEach((item, index) => {
arr.push(item);
if ((index + 1) % segmentNum === 0) {
_list.push(arr);
arr = [];
}
});
// 处理余数
const restList = list.slice(_list.length * segmentNum);
if (restList.length) {
_list.push(restList);
}
initList = _list;
};
计算渲染高度
在处理完数据后,接着我们就是取出每一屏的数据进行渲染,接着计算并存储渲染后每一屏所占用的高度,在后续滚动过程中,如果该屏内容离开可视区,我们就可以将该屏的内容替换成对应高度进行渲染占位,这样就可以减少真实渲染的节点数。
// 计算每一页数据渲染完成后所占的高度
const setheight = (list: any[], pageIndex?: number) => {
const index = pageIndex ?? renderPageIndex.value;
const query = Taro.createSelectorQuery();
query.select(`.inner_list_${index}`).boundingClientRect();
query.exec((res) => {
if (list?.length) {
pageHeightArr.value.push(res?.[0]?.height); // 存储每一屏真实渲染高度
}
});
observePageHeight(pageIndex); // 监听页面高度
};
监听每一屏是否在可视区
上面提到的当每一屏的内容离开可视区就需要将该屏内容替换为占位高度,这个功能的实现就需要借助Taro.createIntersectionObserver这个API来完成。
这里需要注意的是relativeToViewport可以自定义监视区域,如果想要滚动过程减少白屏概率,那么可以将监视区域扩大,但渲染性能也会随之变差,所有这里可以按自己的业务需要考量
const observePageHeight = (pageIndex?: number) => {
const index = pageIndex ?? renderPageIndex.value;
observer = Taro.createIntersectionObserver(
currentPage.page as any,
).relativeToViewport({
top: props?.screenNum * pageHeight,
bottom: props?.screenNum * pageHeight,
});
console.log('observer', observer);
// console.log("index", `.inner_list_${index}`);
observer?.observe(`.inner_list_${index}`, (res) => {
console.log(`.inner_list_${index}`, res.intersectionRatio);
if (res.intersectionRatio <= 0) {
// 当没有交集时,说明当前页面已经不在视口内,则将该屏数据修改为该屏高度进行占位
towList.value[index] = {
height: pageHeightArr.value[index],
};
} else {
// 当有交集时,说明当前页面在视口内
if (!towList.value[index]?.length) {
towList.value[index] = initList[index];
}
}
});
};
触底监听
UI层,使用了scrollView组件来渲染列表,真实列表项渲染提供插槽给外部自行处理
<scroll-view
v-if="list?.length"
class="list"
:scrollY="true"
:showScrollbar="false"
:lowerThreshold="lowerThreshold"
:scrollTop="scrollTop"
@scrollToLower="renderNext"
:enhanced="true"
:bounces="false"
:enablePassive="true"
:style="{ height: height }"
>
<view
:class="[`inner_list_${pageIndex}`]"
:id="`inner_list_${pageIndex}`"
v-for="(page, pageIndex) in towList"
:key="pageIndex"
>
<template v-if="page?.length > 0">
<view
:id="`item_${pageIndex}_${index}`"
v-for="(item, index) in page"
:key="index"
>
<slot v-if="item" name="listItem" :item="item"></slot>
</view>
</template>
<view v-else :style="{ height: `${pageHeightArr[pageIndex]}px` }">
</view>
</view>
<!-- 底部自定义内容 -->
<slot name="renderBottom"></slot>
</scroll-view>
通过lowerThreshold监听触底操作,将二维数组每一项取出来渲染,当每一页的内容都渲染完后,那么页面最终的所有节点将会是:真实列表内容 + 占位高度,后续只需要依赖上一步骤的监听就可以完成真实内容渲染与占位高度之间的切换。
// 渲染下一页
const renderNext = () => {
// if (!towList.value[pageIndex]?.length) {
// // 无数据
// }
renderPageIndex.value += 1; // 更新当前页索引
if (renderPageIndex.value >= initList.length) {
// 已经到底
return;
}
towList.value[renderPageIndex.value] = initList[renderPageIndex.value];
Taro.nextTick(() => {
setheight(props?.list);
});
};
这样基本就完成一个虚拟列表组件,我们来看看效果:
// 渲染数据
const list = ref(
new Array(10000).fill(0).map((_, i) => {
return {
label: `第 ${i} 章`,
value: i,
isLock: false,
time: "2023-01-12 16:07",
type: "chapter",
};
}),
); // 列表数据
这里模拟了10000条数据来测试:
图片
初始渲染只有两页内容,每一页渲染20条。
当我们滚动页面时,就会根据监听来加入渲染内容,并且将不在可视区的内容替换成占位高度。
图片
但是我们的业务还需要定位功能,定位到某一章高亮,这里就需要计算滚动高度了,虽然scrollView组件提供了scrollIntoView属性,可以使列表滚动到对应子元素位置,但是我发现只有它的第一层子元素能够生效,对于他的孙子元素并不生效。
定高滚动至指定位置
由于我这里是按页来渲染的,需要定位到的元素并不是它的第一层子元素,所以这个方法在这里并不适用,最终只能计算滚动高度来实现。
const formateList = (list: any[]): void => {
const scrollToIndex = props?.scrollToIndex; // 滚动到指定位置
const itemHeight =
itemRenderHeight.value || (props?.itemHeight ?? 0) * (pageWidth / 375); // 每一项的真实渲染高度
const segmentNum = props?.segmentNum; // 每页显示数量
dealList(list);
if (itemHeight && scrollToIndex !== undefined) {
// 定高,可滚动至指定位置
// console.log("scrollToIndex", scrollToIndex);
const startIndex = Math.floor(scrollToIndex / segmentNum); // 找到当前索引所在的页面
console.log("startIndex", startIndex);
renderPageIndex.value = startIndex; // 更新当前页索引
const pageHeight = segmentNum * itemHeight; // 一屏的高度
console.log("pageHeight", pageHeight, itemHeight);
// readyList
for (let i = 0; i < startIndex; i++) {
pageHeightArr.value[i] = pageHeight;
towList.value[i] = {
height: pageHeight,
};
}
towList.value[startIndex] = initList[startIndex];
if (startIndex + 1 < initList.length) {
towList.value[startIndex + 1] = initList[startIndex + 1];
}
Taro.nextTick(() => {
for (let i = 0; i < startIndex; i++) {
// observePageHeight(i);
setheight(list, i);
}
scrollTop.value = scrollToIndex * itemHeight;
console.log("scrollTop---", scrollTop.value);
});
} else {
// console.log("当前为不定高虚拟列表");
towList.value = initList.slice(0, 1);
Taro.nextTick(() => {
setheight(list);
});
}
};
通过scrollToIndex计算出需要定位到的位置。
图片
这里需要注意的是,在通过scrollToIndex找到该节点即将渲染在第几页,在这之前的几页都需要我们手动执行以下监听每一屏是否在可视区,因为在这之后的页都会通过触底这一操作来执行监听。如果少了这一步那么之前的这几页都会白屏,无真实数据渲染。