本文是深入淺出 ahooks 原始碼系列文章的第十八篇,該系列已整理成檔案-地址。覺得還不錯,給個 star 支援一下哈,Thanks。
提供虛擬化列表能力的 Hook,用於解決展示海量資料渲染時首屏渲染緩慢和捲動卡頓問題。
其實現原理監聽外部容器的 scroll 事件以及其 size 發生變化的時候,觸發計算邏輯算出內部容器的高度和 marginTop 值。
其監聽捲動邏輯如下:
// 當外部容器的 size 發生變化的時候,觸發計算邏輯
useEffect(() => {
if (!size?.width || !size?.height) {
return;
}
// 重新計算邏輯
calculateRange();
}, [size?.width, size?.height, list]);
// 監聽外部容器的 scroll 事件
useEventListener(
'scroll',
e => {
// 如果是直接跳轉,則不需要重新計算
if (scrollTriggerByScrollToFunc.current) {
scrollTriggerByScrollToFunc.current = false;
return;
}
e.preventDefault();
// 計算
calculateRange();
},
{
// 外部容器
target: containerTarget,
},
);
其中 calculateRange 非常重要,它基本實現了虛擬捲動的主流程邏輯,其主要做了以下的事情:
變數很多,可以結合下圖,會比較清晰理解:
程式碼如下:
// 計算範圍,由哪個開始,哪個結束
const calculateRange = () => {
// 獲取外部和內部容器
// 外部容器
const container = getTargetElement(containerTarget);
// 內部容器
const wrapper = getTargetElement(wrapperTarget);
if (container && wrapper) {
const {
// 捲動距離頂部的距離。設定或獲取位於物件最頂端和視窗中可見內容的最頂端之間的距離
scrollTop,
// 內容可視區域的高度
clientHeight,
} = container;
// 根據外部容器的 scrollTop 算出已經「滾過」多少項
const offset = getOffset(scrollTop);
// 可視區域的 DOM 個數
const visibleCount = getVisibleCount(clientHeight, offset);
// 開始的下標
const start = Math.max(0, offset - overscan);
// 結束的下標
const end = Math.min(list.length, offset + visibleCount + overscan);
// 獲取上方高度
const offsetTop = getDistanceTop(start);
// 設定內部容器的高度,總的高度 - 上方高度
// @ts-ignore
wrapper.style.height = totalHeight - offsetTop + 'px';
// margin top 為上方高度
// @ts-ignore
wrapper.style.marginTop = offsetTop + 'px';
// 設定最後顯示的 List
setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
})),
);
}
};
其它就是這個函數的輔助函數了,包括:
// 根據外部容器以及內部每一項的高度,計算出可視區域內的數量
const getVisibleCount = (containerHeight: number, fromIndex: number) => {
// 知道每一行的高度 - number 型別,則根據容器計算
if (isNumber(itemHeightRef.current)) {
return Math.ceil(containerHeight / itemHeightRef.current);
}
// 動態指定每個元素的高度情況
let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
// 計算每一個 Item 的高度
const height = itemHeightRef.current(i, list[i]);
sum += height;
endIndex = i;
// 大於容器寬度的時候,停止
if (sum >= containerHeight) {
break;
}
}
// 最後一個的下標減去開始一個的下標
return endIndex - fromIndex;
};
// 根據 scrollTop 計算上面有多少個 DOM 節點
const getOffset = (scrollTop: number) => {
// 每一項固定高度
if (isNumber(itemHeightRef.current)) {
return Math.floor(scrollTop / itemHeightRef.current) + 1;
}
// 動態指定每個元素的高度情況
let sum = 0;
let offset = 0;
// 從 0 開始
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
// 滿足要求的最後一個 + 1
return offset + 1;
};
// 獲取上部高度
const getDistanceTop = (index: number) => {
// 每一項高度相同
if (isNumber(itemHeightRef.current)) {
const height = index * itemHeightRef.current;
return height;
}
// 動態指定每個元素的高度情況,則 itemHeightRef.current 為函數
const height = list
.slice(0, index)
// reduce 計算總和
// @ts-ignore
.reduce((sum, _, i) => sum + itemHeightRef.current(i, list[index]), 0);
return height;
};
// 計算總的高度
const totalHeight = useMemo(() => {
// 每一項高度相同
if (isNumber(itemHeightRef.current)) {
return list.length * itemHeightRef.current;
}
// 動態指定每個元素的高度情況
// @ts-ignore
return list.reduce(
(sum, _, index) => sum + itemHeightRef.current(index, list[index]),
0,
);
}, [list]);
最後暴露一個捲動到指定的 index 的函數,其主要是計算出該 index 距離頂部的高度 scrollTop,設定給外部容器。並觸發 calculateRange 函數。
// 捲動到指定的 index
const scrollTo = (index: number) => {
const container = getTargetElement(containerTarget);
if (container) {
scrollTriggerByScrollToFunc.current = true;
// 捲動
container.scrollTop = getDistanceTop(index);
calculateRange();
}
};
對於高度相對比較確定的情況,我們做虛擬捲動還是相對簡單的,但假如高度不確定呢?
或者換另外一個角度,當我們的捲動不是縱向的時候,而是橫向,該如何處理呢?
本文已收錄到個人部落格中,歡迎關注~