從原理到實踐:如何在Taro中構(gòu)建高效且易用的虛擬列表
前言
最近在小程序的工作中有許多場景是大數(shù)據(jù)量的列表渲染,這種渲染場景如果不對它進(jìn)行優(yōu)化會非常耗性能,常見的優(yōu)化手段有:分片渲染與虛擬列表,恰好Taro官方也有提供虛擬列表組件,但有同事反饋這個組件并不好用,體驗也不好,白屏非常明顯。
在虛擬列表中滾動過快造成的白屏其實是必然的,關(guān)鍵在于我們怎么去優(yōu)化它,把白屏比例盡量降到最低,這其中還得權(quán)衡性能與體驗,想要白屏越少,那么你要渲染的節(jié)點就越多,性能自然也就越差。反之,你想要性能好,那么白屏就會增加。
接下來,自己動手實現(xiàn)一個虛擬列表,支持以下功能:
- 元素定高,可支持滾動到指定元素位置
- 元素不定高,無需關(guān)心子元素的高度,組件內(nèi)自動計算
- 可支持一次性加載所有數(shù)據(jù),也支持分頁加載數(shù)據(jù)
原理介紹
虛擬列表的原理其實就是:只渲染可視區(qū)內(nèi)的元素,對于非可視區(qū)的元素不進(jìn)行渲染,這樣就可以提高長列表的渲染性能。
但是為了在滾動過程盡可能的降低白屏率,我們可以多渲染幾屏元素
圖片
如上圖,原理大概是這樣:
- 將數(shù)據(jù)處理成每一屏一個渲染單位,用二維數(shù)組存儲
- 使用Taro.createIntersectionObserver監(jiān)聽每一屏內(nèi)容是否在可視區(qū),在可視區(qū)直接渲染,不在可視區(qū)則使用該屏真實高度進(jìn)行占位。高度怎么來?可以定高,也可以不定高,不定高的話就是等每一屏渲染完成后記錄渲染高度即可,兩種都有實現(xiàn),具體可以看下面的實現(xiàn)方法
這樣的話,每次真實渲染的節(jié)點數(shù)就遠(yuǎn)小于列表全部渲染的節(jié)點數(shù),可以極大地提高頁面渲染性能。
實現(xiàn)
處理數(shù)據(jù)
首先將外部的數(shù)據(jù)處理成二維數(shù)組,方便后續(xù)按屏渲染
// 處理列表數(shù)據(jù),按規(guī)則分割
let initList: any[] = []; // 初始列表(備用)
const dealList = (list: any[]) => {
const segmentNum = props?.segmentNum; // 每頁顯示數(shù)量
let arr: any[] = [];
const _list: any[] = [];
list.forEach((item, index) => {
arr.push(item);
if ((index + 1) % segmentNum === 0) {
_list.push(arr);
arr = [];
}
});
// 處理余數(shù)
const restList = list.slice(_list.length * segmentNum);
if (restList.length) {
_list.push(restList);
}
initList = _list;
};
計算渲染高度
在處理完數(shù)據(jù)后,接著我們就是取出每一屏的數(shù)據(jù)進(jìn)行渲染,接著計算并存儲渲染后每一屏所占用的高度,在后續(xù)滾動過程中,如果該屏內(nèi)容離開可視區(qū),我們就可以將該屏的內(nèi)容替換成對應(yīng)高度進(jìn)行渲染占位,這樣就可以減少真實渲染的節(jié)點數(shù)。
// 計算每一頁數(shù)據(jù)渲染完成后所占的高度
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); // 監(jiān)聽頁面高度
};
監(jiān)聽每一屏是否在可視區(qū)
上面提到的當(dāng)每一屏的內(nèi)容離開可視區(qū)就需要將該屏內(nèi)容替換為占位高度,這個功能的實現(xiàn)就需要借助Taro.createIntersectionObserver這個API來完成。
這里需要注意的是relativeToViewport可以自定義監(jiān)視區(qū)域,如果想要滾動過程減少白屏概率,那么可以將監(jiān)視區(qū)域擴(kuò)大,但渲染性能也會隨之變差,所有這里可以按自己的業(yè)務(wù)需要考量
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) {
// 當(dāng)沒有交集時,說明當(dāng)前頁面已經(jīng)不在視口內(nèi),則將該屏數(shù)據(jù)修改為該屏高度進(jìn)行占位
towList.value[index] = {
height: pageHeightArr.value[index],
};
} else {
// 當(dāng)有交集時,說明當(dāng)前頁面在視口內(nèi)
if (!towList.value[index]?.length) {
towList.value[index] = initList[index];
}
}
});
};
觸底監(jiān)聽
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>
<!-- 底部自定義內(nèi)容 -->
<slot name="renderBottom"></slot>
</scroll-view>
通過lowerThreshold監(jiān)聽觸底操作,將二維數(shù)組每一項取出來渲染,當(dāng)每一頁的內(nèi)容都渲染完后,那么頁面最終的所有節(jié)點將會是:真實列表內(nèi)容 + 占位高度,后續(xù)只需要依賴上一步驟的監(jiān)聽就可以完成真實內(nèi)容渲染與占位高度之間的切換。
// 渲染下一頁
const renderNext = () => {
// if (!towList.value[pageIndex]?.length) {
// // 無數(shù)據(jù)
// }
renderPageIndex.value += 1; // 更新當(dāng)前頁索引
if (renderPageIndex.value >= initList.length) {
// 已經(jīng)到底
return;
}
towList.value[renderPageIndex.value] = initList[renderPageIndex.value];
Taro.nextTick(() => {
setheight(props?.list);
});
};
這樣基本就完成一個虛擬列表組件,我們來看看效果:
// 渲染數(shù)據(jù)
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",
};
}),
); // 列表數(shù)據(jù)
這里模擬了10000條數(shù)據(jù)來測試:
圖片
初始渲染只有兩頁內(nèi)容,每一頁渲染20條。
當(dāng)我們滾動頁面時,就會根據(jù)監(jiān)聽來加入渲染內(nèi)容,并且將不在可視區(qū)的內(nèi)容替換成占位高度。
圖片
但是我們的業(yè)務(wù)還需要定位功能,定位到某一章高亮,這里就需要計算滾動高度了,雖然scrollView組件提供了scrollIntoView屬性,可以使列表滾動到對應(yīng)子元素位置,但是我發(fā)現(xiàn)只有它的第一層子元素能夠生效,對于他的孫子元素并不生效。
定高滾動至指定位置
由于我這里是按頁來渲染的,需要定位到的元素并不是它的第一層子元素,所以這個方法在這里并不適用,最終只能計算滾動高度來實現(xiàn)。
const formateList = (list: any[]): void => {
const scrollToIndex = props?.scrollToIndex; // 滾動到指定位置
const itemHeight =
itemRenderHeight.value || (props?.itemHeight ?? 0) * (pageWidth / 375); // 每一項的真實渲染高度
const segmentNum = props?.segmentNum; // 每頁顯示數(shù)量
dealList(list);
if (itemHeight && scrollToIndex !== undefined) {
// 定高,可滾動至指定位置
// console.log("scrollToIndex", scrollToIndex);
const startIndex = Math.floor(scrollToIndex / segmentNum); // 找到當(dāng)前索引所在的頁面
console.log("startIndex", startIndex);
renderPageIndex.value = startIndex; // 更新當(dāng)前頁索引
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("當(dāng)前為不定高虛擬列表");
towList.value = initList.slice(0, 1);
Taro.nextTick(() => {
setheight(list);
});
}
};
通過scrollToIndex計算出需要定位到的位置。
圖片
這里需要注意的是,在通過scrollToIndex找到該節(jié)點即將渲染在第幾頁,在這之前的幾頁都需要我們手動執(zhí)行以下監(jiān)聽每一屏是否在可視區(qū),因為在這之后的頁都會通過觸底這一操作來執(zhí)行監(jiān)聽。如果少了這一步那么之前的這幾頁都會白屏,無真實數(shù)據(jù)渲染。