基于React實現(xiàn)虛擬滾動的方案詳解
在渲染列表時我們通常會一次性將所有列表項渲染到DOM中,在數(shù)據(jù)量大的時候這種操作會造成頁面響應緩慢,因為瀏覽器需要處理大量的DOM元素。而此時我們通常就需要虛擬滾動來實現(xiàn)性能優(yōu)化,當我們擁有大量數(shù)據(jù)需要在用戶界面中以列表或表格的形式展示時,這種性能優(yōu)化方式可以大幅改善用戶體驗和應用性能,那么在本文中就以固定高度和非固定高度兩種場景展開虛擬滾動的實現(xiàn)。
1.描述
實現(xiàn)虛擬滾動通常并不是非常復雜的事情,但是我們需要考慮到很多細節(jié)問題。在具體實現(xiàn)之前我思考了一個比較有意思的事情,為什么虛擬滾動能夠優(yōu)化性能。我們在瀏覽器中進行DOM操作的時候,此時這個DOM是真正存在的嗎,或者說我們在PC上實現(xiàn)窗口管理的時候,這個窗口是真的存在的嗎。那么答案實際上很明確,這些視圖、窗口、DOM等等都是通過圖形化模擬出來的,雖然我們可以通過系統(tǒng)或者瀏覽器提供的API來非常簡單地實現(xiàn)各種操作,但是實際上些內容是系統(tǒng)幫我們繪制出來的圖像,本質上還是通過外部輸入設備產生各種事件信號,從而產生狀態(tài)與行為模擬,諸如碰撞檢測等等都是系統(tǒng)通過大量計算表現(xiàn)出的狀態(tài)而已。
那么緊接著,在前段時間我想學習下Canvas的基本操作,于是我實現(xiàn)了一個非?;A的圖形編輯器引擎。因為在瀏覽器的Canvas只提供了最基本的圖形操作,沒有那么方便的DOM操作從而所有的交互事件都需要通過鼠標與鍵盤事件自行模擬,這其中有一個非常重要的點是判斷兩個圖形是否相交,從而決定是否需要按需重新繪制這個圖形來提升性能。
那么我們設想一下,最簡單的判斷方式就是遍歷一遍所有圖形,從而判斷是否與即將要刷新的圖形相交,那么這其中就可能涉及比較復雜的計算,而如果我們能夠提前判斷某些圖形是不可能相交的話,就能夠省去很多不必要的計算。那么在視口外的圖層就是類似的情況,如果我們能夠確定這個圖形是視口外的,我們就不需要判斷其相交性,而且本身其也不需要渲染,那么虛擬滾動也是一樣,如果我們能夠減少DOM的數(shù)量就能夠減少很多計算,從而提升整個頁面的運行時性能,至于首屏性能就自不必多說,減少了DOM數(shù)量首屏的繪制一定會變快。
當然上邊只是我對于提升頁面交互或者說運行時性能的思考,實際上關于虛擬滾動優(yōu)化性能的點在社區(qū)上有很多討論了。諸如減少DOM數(shù)量可以減少瀏覽器需要渲染和維持的DOM元素數(shù)量,進而內存占用也隨之減少,這使得瀏覽器可以更快地響應用戶操作。以及瀏覽器的reflow和重繪repaint操作通常是需要大量計算的,并且隨著DOM元素的增多而變得更加頻繁和復雜,通過虛擬滾動個減少需要管理的DOM數(shù)量,同樣可顯著提高渲染性能。此
外虛擬滾動還有更快的首屏渲染時間,特別是超大列表的全量渲染很容易導致首屏渲染時間過長,還能夠減少React維護組件狀態(tài)所帶來的Js性能消耗,特別是在存在Context的情況下,不特別關注就可能會存在性能劣化問題。
文中會提到4種虛擬滾動的實現(xiàn)方式,分別有固定高度的OnScroll實現(xiàn)和不定高度的IntersectionObserver+OnScroll實現(xiàn),相關DEMO都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/react-virtual-list中。
2.固定高度
實際上關于虛擬滾動的方案在社區(qū)有很多參考,特別是固定高度的虛擬滾動實際上可以做成非常通用的解決方案。那么在這里我們以ArcoDesign的List組件為例來研究一下通用的虛擬滾動實現(xiàn)。在Arco給予的示例中我們可以看到其傳遞了height屬性,此時如果我們將這個屬性刪除的話虛擬滾動是無法正常啟動的。
那么實際上Arco就是通過列表元素的數(shù)量與每個元素的高度,從而計算出了整個容器的高度,這里要注意滾動容器實際上應該是虛擬滾動的容器外的元素,而對于視口內的區(qū)域則可以通過transform: translateY(Npx)來做實際偏移。當我們滾動的時候,我們需要通過滾動條的實際滾動距離以及滾動容器的高度,配合我們配置的元素實際高度,就可以計算出來當前視口實際需要渲染的節(jié)點,而其他的節(jié)點并不實際渲染,從而實現(xiàn)虛擬滾動。當然實際上關于Arco虛擬滾動的配置還有很多,在這里就不完整展開了。
<List
{/* ... */}
virtualListProps={{
height: 560,
}}
{/* ... */}
/>那么我們可以先來設想一下,當我們有了每個元素的高度以及元素數(shù)量,很明顯我們就可以計算出容器的高度了,當我們有了容器的高度,此時滾動容器的子元素就可以得到,此時我們就可以得到擁有滾動條的滾動容器了。
// packages/fixed-height-scroll/src/index.tsx
// ...
const totalHeight = useMemo(() => itemHeight * list.length, [itemHeight, list.length]);
// ...
<div
style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
onScroll={onScroll.run}
ref={setScroll}
>
{scroll && (
<div style={{ height: totalHeight, position: "relative", overflow: "hidden" }}>
{/* ... */}
</div>
)}
</div>那么既然滾動容器已經有了,我們現(xiàn)在就需要關注于我們即將要展示的列表元素,因為我們是存在滾動條且實際有滾動偏移的,所以我們的滾動條位置需要鎖定在我們的視口位置上。我們只需要使用scrollTop / itemHeight取整即可,并且這里我們使用translateY來做整體偏移,使用translate還可以觸發(fā)硬件加速。那么除了列表的整體偏移之外,我們還需要計算出當前視口內的元素數(shù)量,這里的計算同樣非常簡單,因為我們的高度固定了,此時只需要跟滾動容器相除即可,實際上這部分在實例化組件的時候就已經完成了。
useEffect(() => {
if (!scroll) return void 0;
setLen(Math.ceil(scroll.clientHeight / itemHeight));
}, [itemHeight, scroll]);
const onScroll = useThrottleFn(
() => {
const containerElement = container.current;
if (!scroll || !containerElement) return void 0;
const scrollTop = scroll.scrollTop;
const newIndex = Math.floor(scrollTop / itemHeight);
containerElement.style.transform = `translateY(${newIndex * itemHeight}px)`;
setIndex(newIndex);
},
{ wait: 17 }
);3.動態(tài)高度
固定高度的虛擬滾動是比較適用于通用的場景的,實際上此處的固定高度不一定是指元素的高度是固定的,而是指元素的高度是可以直接計算得到而不是必須要渲染之后才能得到,例如圖片的寬高是可以在上傳時保存,然后在渲染時通過圖片寬高以及容器寬度計算得到的。然而實際上我們有很多場景下并不臺能夠完全做到元素的固定高度,例如在線文檔場景下的富文本編輯器中,特別是文本塊的高度,在不同的字體、瀏覽器寬度等情況下表現(xiàn)是不同的。
我們無法在其渲染之前的到其高度,這就導致了我們無法像圖片一樣提前計算出其占位高度,從而對于文檔塊結構的虛擬滾動就必須要解決塊高度不固定的問題,由此我們需要實現(xiàn)動態(tài)高度的虛擬滾動調度策略來處理這個問題。
3.1IntersectionObserver占位符
如果我們需要判斷元素是否出現(xiàn)在視口當中時,通常會監(jiān)聽onScroll事件用來判斷元素實際位置,而現(xiàn)如今絕大多數(shù)瀏覽器都提供了IntersectionObserver原生對象,用以異步地觀察目標元素與其祖先元素或頂級文檔視口的交叉狀態(tài),這對判斷元素是否出現(xiàn)在視口范圍非常有用,那么同樣的,我們也可以借助IntersectionObserver來實現(xiàn)虛擬滾動。
需要注意的是,IntersectionObserver對象的應用場景是觀察目標元素與視口的交叉狀態(tài),而我們的虛擬滾動核心概念是不渲染非視口區(qū)域的元素,所以這里邊實際上出現(xiàn)了一個偏差,在虛擬滾動中目標元素都不存在或者說并未渲染,那么此時是無法觀察其狀態(tài)的。所以為了配合IntersectionObserver的概念,我們需要渲染實際的占位符,例如10k個列表的節(jié)點,我們首先就需要渲染10k個占位符,實際上這也是一件合理的事,除非我們最開始就注意到列表的性能問題,而實際上大部分都是后期優(yōu)化頁面性能,特別是在復雜的場景下例如文檔中,所以假設原本有1w條數(shù)據(jù),每條數(shù)據(jù)即使僅渲染3個節(jié)點,那么此時我們如果僅渲染占位符的情況下還能將原本頁面30k個節(jié)點優(yōu)化到大概10k個節(jié)點,這對于性能提升本身也是非常有意義的。
此外,在https://caniuse.com/?search=IntersectionObserver可以觀察到兼容性還是不錯的,在瀏覽器不支持的情況下可以采用OnScroll方案或者考慮使用polyfill。那么緊接著,我們來實現(xiàn)這部分內容,首先我們需要生成數(shù)據(jù),在這里需要注意的是我們所說的不定高度實際上應該是被稱為動態(tài)高度,元素的高度是需要我們實際渲染之后才能得到的,在渲染之前我們僅以估算的高度占位,從而能夠使?jié)L動容器產生滾動效果。
// packages/dynamic-height-placeholder/src/index.tsx
const LIST = Array.from({ length: 1000 }, (_, i) => {
const height = Math.floor(Math.random() * 30) + 60;
return {
id: i,
content: (
<div style={{ height }}>
{i}-高度:{height}
</div>
),
};
});接下來我們需要創(chuàng)建IntersectionObserver,同樣的因為我們的滾動容器可能并不一定是window,所以我們需要在滾動容器上創(chuàng)建IntersectionObserver,此外通常我們會對視口區(qū)域做一層buffer,用來提前加載視口外的元素,這樣可以避免用戶滾動時出現(xiàn)空白區(qū)域,這個buffer的大小通常選擇當前視口高度的一半。
useLayoutEffect(() => {
if (!scroll) return void 0;
// 視口閾值 取滾動容器高度的一半
const margin = scroll.clientHeight / 2;
const current = new IntersectionObserver(onIntersect, {
root: scroll,
rootMargin: `${margin}px 0px`,
});
setObserver(current);
return () => {
current.disconnect();
};
}, [onIntersect, scroll]);接下來我們需要對占位節(jié)點的狀態(tài)進行管理,因為我們此時有實際占位,所以就不再需要預估整個容器的高度,而且只需要實際滾動到相關位置將節(jié)點渲染即可。我們?yōu)楣?jié)點設置三個狀態(tài),loading狀態(tài)即占位狀態(tài),此時節(jié)點只渲染空的占位符也可以渲染一個loading標識,此時我們還不知道這個節(jié)點的實際高度;viewport狀態(tài)即為節(jié)點真實渲染狀態(tài),也就是說節(jié)點在邏輯視口內,此時我們可以記錄節(jié)點的真實高度;placeholder狀態(tài)為渲染后的占位狀態(tài),相當于節(jié)點從在視口內滾動到了視口外,此時節(jié)點的高度已經被記錄,我們可以將節(jié)點的高度設置為真實高度。
loading -> viewport <-> placeholder
type NodeState = {
mode: "loading" | "placeholder" | "viewport";
height: number;
};
public changeStatus = (mode: NodeState["mode"], height: number): void => {
this.setState({ mode, height: height || this.state.height });
};
render() {
return (
<div ref={this.ref} data-state={this.state.mode}>
{this.state.mode === "loading" && (
<div style={{ height: this.state.height }}>loading...</div>
)}
{this.state.mode === "placeholder" && <div style={{ height: this.state.height }}></div>}
{this.state.mode === "viewport" && this.props.content}
</div>
);
}當然我們的Observer的觀察同樣需要配置,這里需要注意的是IntersectionObserver的回調函數(shù)只會攜帶target節(jié)點信息,我們需要通過節(jié)點信息找到我們實際的Node來管理節(jié)點狀態(tài),所以此處我們借助WeakMap來建立元素到節(jié)點的關系,從而方便我們處理。
export const ELEMENT_TO_NODE = new WeakMap<Element, Node>();
componentDidMount(): void {
const el = this.ref.current;
if (!el) return void 0;
ELEMENT_TO_NODE.set(el, this);
this.observer.observe(el);
}
componentWillUnmount(): void {
const el = this.ref.current;
if (!el) return void 0;
ELEMENT_TO_NODE.delete(el);
this.observer.unobserve(el);
}最后就是實際滾動調度了,當節(jié)點出現(xiàn)在視口時我們需要根據(jù)ELEMENT_TO_NODE獲取節(jié)點信息,然后根據(jù)當前視口信息來設置狀態(tài),如果當前節(jié)點是進入視口的狀態(tài)我們就將節(jié)點狀態(tài)設置為viewport,如果此時是出視口的狀態(tài)則需要二次判斷當前狀態(tài),如果不是初始的loading狀態(tài)則可以直接將高度與placeholder設置到節(jié)點狀態(tài)上,此時節(jié)點的高度就是實際高度。
const onIntersect = useMemoizedFn((entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
const node = ELEMENT_TO_NODE.get(entry.target);
if (!node) {
console.warn("Node Not Found", entry.target);
return void 0;
}
const rect = entry.boundingClientRect;
if (entry.isIntersecting || entry.intersectionRatio > 0) {
// 進入視口
node.changeStatus("viewport", rect.height);
} else {
// 脫離視口
if (node.state.mode !== "loading") {
node.changeStatus("placeholder", rect.height);
}
}
});
});3.2IntersectionObserver虛擬化
在前邊我們也提到了IntersectionObserver的目標是觀察目標元素與視口的交叉狀態(tài),而我們的虛擬滾動核心概念是不渲染非視口區(qū)域的元素,那么究竟能不能通過IntersectionObserver實現(xiàn)虛擬滾動的效果,實際上是可以的,但是可能需要OnScroll來輔助節(jié)點的強制刷新。在這里我們嘗試使用標記節(jié)點以及額外渲染的方式來實現(xiàn)虛擬列表,但是要注意的是,在這里因為沒有使用OnScroll來強制刷新節(jié)點,當快速滾動的時候可能會出現(xiàn)空白的情況。
在先前的占位方案中,我們已經實現(xiàn)了IntersectionObserver的基本操作,在這里就不再贅述了。而在這里我們的核心思路是標記虛擬列表節(jié)點的首位,并且節(jié)點的首尾是額外渲染的,相當于首尾節(jié)點是在視口外的節(jié)點,當首尾節(jié)點的狀態(tài)發(fā)生改變時,我們可以通過回調函數(shù)來控制其首尾的指針范圍,從而實現(xiàn)虛擬滾動。那么在這之前,我們需要先控制好首尾指針的狀態(tài),避免出現(xiàn)負值或者越界的情況。
// packages/dynamic-height-virtualization/src/index.tsx
const setSafeStart = useMemoizedFn((next: number | ((index: number) => number)) => {
if (typeof next === "function") {
setStart(v => {
const index = next(v);
return Math.min(Math.max(0, index), list.length);
});
} else {
setStart(Math.min(Math.max(0, next), list.length));
}
});
const setSafeEnd = useMemoizedFn((next: number | ((index: number) => number)) => {
if (typeof next === "function") {
setEnd(v => {
const index = next(v);
return Math.max(Math.min(list.length, index), 1);
});
} else {
setEnd(Math.max(Math.min(list.length, next), 1));
}
});緊接著我們還需要兩個數(shù)組,分別用來管理所有的節(jié)點以及節(jié)點的高度值,因為此時我們的節(jié)點可能是不存在的,所以其狀態(tài)與高度需要額外的變量來管理,并且我們還需要兩個占位塊來作為首尾節(jié)點的占位,用來實現(xiàn)在滾動容器中滾動的效果。占位塊同樣需要對其進行觀察,并且其高度就需要根據(jù)高度值的節(jié)點計算,當然這部分計算寫的比較粗暴,還有很大的優(yōu)化空間,例如額外維護一個單調遞增的隊列來計算高度。
const instances: Node[] = useMemo(() => [], []);
const record = useMemo(() => {
return Array.from({ length: list.length }, () => DEFAULT_HEIGHT);
}, [list]);
<div
ref={startPlaceHolder}
style={{ height: record.slice(0, start).reduce((a, b) => a + b, 0) }}
></div>
// ...
<div
ref={endPlaceHolder}
style={{ height: record.slice(end, record.length).reduce((a, b) => a + b, 0) }}
></div>在節(jié)點渲染時,我們需要標記其狀態(tài),這里的Node節(jié)點的數(shù)據(jù)會變得更多,在這里主要是需要標注isFirstNode、isLastNode兩個狀態(tài),并且initHeight需要從外部傳遞,之前也提到過了,節(jié)點可能不存在,此時如果再從頭加載的話高度會不正確,倒是滾動不流暢的問題,所以我們需要在節(jié)點渲染時傳遞initHeight,這個高度值就是節(jié)點渲染記錄的實際高度或者未渲染過的占位高度。
<Node
scroll={scroll}
instances={instances}
key={item.id}
index={item.id}
id={item.id}
content={item.content}
observer={observer}
isFirstNode={index === 0}
initHeight={record[item.id]}
isLastNode={index === current.length - 1}
></Node>還有一個需要關注的問題是視口鎖定,當在可見區(qū)域之外的節(jié)點高度發(fā)生變化時,如果不進行視口鎖定,就會出現(xiàn)可視區(qū)域跳變的問題。這里還需要注意的是我們不能使用smooth滾動的動畫表現(xiàn),如果使用動畫的話可能會導致滾動的過程中其他節(jié)點高度變更且視口鎖定失效的情況,此時依然會導致視口區(qū)域跳變,我們必須明確地指定滾動的位置,如果實在需要動畫的話,同樣也需要通過明確的數(shù)值緩慢遞增來模擬,而不是直接使用scrollTo的smooth參數(shù)。
componentDidUpdate(prevProps: Readonly<NodeProps>, prevState: Readonly<NodeState>): void {
if (prevState.mode === "loading" && this.state.mode === "viewport" && this.ref.current) {
const rect = this.ref.current.getBoundingClientRect();
const SCROLL_TOP = 0;
if (rect.height !== prevState.height && rect.top < SCROLL_TOP) {
this.scrollDeltaY(rect.height - prevState.height);
}
}
}
private scrollDeltaY = (deltaY: number): void => {
const scroll = this.props.scroll;
if (scroll instanceof Window) {
scroll.scrollTo({ top: scroll.scrollY + deltaY });
} else {
scroll.scrollTop = scroll.scrollTop + deltaY;
}
};接下來就是重點的回調函數(shù)處理了,這里涉及到比較復雜的狀態(tài)管理。首先是兩個占位節(jié)點,當兩個占位節(jié)點出現(xiàn)在視口時,我們認為此時是需要加載其他節(jié)點的,以起始占位節(jié)點為例,當其出現(xiàn)在視口時,我們需要將起始指針前移,而前移的數(shù)量需要根據(jù)實際視口 交叉的范圍計算。
const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0;
if (entry.target === startPlaceHolder.current) {
// 起始占位符進入視口
if (isIntersecting && entry.target.clientHeight > 0) {
const delta = entry.intersectionRect.height || 1;
let index = start - 1;
let count = 0;
let increment = 0;
while (index >= 0 && count < delta) {
count = count + record[index];
increment++;
index--;
}
setSafeStart(index => index - increment);
}
return void 0;
}
if (entry.target === endPlaceHolder.current) {
// 結束占位符進入視口
if (isIntersecting && entry.target.clientHeight > 0) {
// ....
setSafeEnd(end => end + increment);
}
return void 0;
}接下來跟占位方案一樣,我們同樣需要根據(jù)ELEMENT_TO_NODE來獲取節(jié)點信息,然后此時需要更新我們的高度記錄變量。由于我們在IntersectionObserver回調中無法判斷實際滾動方向,也不容易判斷實際滾動范圍,所以此時我們需要根據(jù)之前提到的isFirstNode與isLastNode信息來控制首尾游標指針。FirstNode進入視口認為是向下滾動,此時需要將上方范圍的節(jié)點渲染出來,而LastNode進入視口認為是向上滾動,此時需要將下方范圍的節(jié)點渲染出來。FirstNode脫離視口認為是向上滾動,此時需要將上方范圍的節(jié)點移除,而LastNode脫離視口認為是向下滾動,此時需要將下方范圍的節(jié)點移除。這里可以注意到我們增加節(jié)點范圍使用的是THRESHOLD,而減少節(jié)點范圍使用的是1,這里就是我們需要額外渲染的首尾節(jié)點。
const node = ELEMENT_TO_NODE.get(entry.target);
const rect = entry.boundingClientRect;
record[node.props.index] = rect.height;
if (isIntersecting) {
// 進入視口
if (node.props.isFirstNode) {
setSafeStart(index => index - THRESHOLD);
}
if (node.props.isLastNode) {
setSafeEnd(end => end + THRESHOLD);
}
node.changeStatus("viewport", rect.height);
} else {
// 脫離視口
if (node.props.isFirstNode) {
setSafeStart(index => index + 1);
}
if (node.props.isLastNode) {
setSafeEnd(end => end - 1);
}
if (node.state.mode !== "loading") {
node.changeStatus("placeholder", rect.height);
}
}在最后,因為這個狀態(tài)很難控制的比較完善,我們還需要為其做兜底處理,防止頁面上遺留過多節(jié)點。當然實際上即使遺留了節(jié)點也沒有問題,相當于降級到了我們上邊提到的占位方案,實際上并不會出現(xiàn)大量的節(jié)點,相當于在這里實現(xiàn)的是懶加載的占位節(jié)點。不過我們在這里依然給予了處理方案,可以通過節(jié)點狀態(tài)來標識節(jié)點是否是作為分界線需要實際處理為首尾游標邊界。
public prevNode = (): Node | null => {
return this.props.instances[this.props.index - 1] || null;
};
public nextNode = (): Node | null => {
return this.props.instances[this.props.index + 1] || null;
};
// ...
const prev = node.prevNode();
const next = node.nextNode();
const isActualFirstNode = prev?.state.mode !== "viewport" && next?.state.mode === "viewport";
const isActualLastNode = prev?.state.mode === "viewport" && next?.state.mode !== "viewport";
if (isActualFirstNode) {
setSafeStart(node.props.index - THRESHOLD);
}
if (isActualLastNode) {
setSafeEnd(node.props.index + THRESHOLD);
}3.3OnScroll滾動事件監(jiān)聽
那么實現(xiàn)動態(tài)高度的虛擬滾動,我們也不能忘記常用的OnScroll方案,實際上相對于使用IntersectionObserver來說,單純的虛擬滾動OnScroll方案更加簡單,當然同樣的也更加容易出現(xiàn)性能問題。使用OnScroll的核心思路同樣是需要一個滾動容器,然后我們需要監(jiān)聽滾動事件,當滾動事件觸發(fā)時,我們需要根據(jù)滾動的位置來計算當前視口內的節(jié)點,然后根據(jù)節(jié)點的高度來計算實際需要渲染的節(jié)點,從而實現(xiàn)虛擬滾動。
那么動態(tài)高度的虛擬滾動與最開始我們實現(xiàn)的固定高度的虛擬滾動區(qū)別在哪,首先是滾動容器的高度,我們在最開始不能夠知道滾動容器實際有多高,而是在不斷渲染的過程中才能知道實際高度;其次我們不能直接根據(jù)滾動的高度計算出當前需要渲染的節(jié)點,在之前我們渲染的起始index游標是直接根據(jù)滾動容器高度和列表所有節(jié)點總高度算出來的,而在動態(tài)高度的虛擬滾動中,我們無法獲得總高度,同樣的渲染節(jié)點的長度也是如此,我們無法得知本次渲染究竟需要渲染多少節(jié)點;再有我們不容易判斷節(jié)點距離滾動容器頂部的高度,也就是之前我們提到的translateY,我們需要使用這個高度來撐起滾動的區(qū)域,從而讓我們能夠實際做到滾動。
那么我們說的這些數(shù)值都是無法計算的嘛,顯然不是這樣的,在我們沒有任何優(yōu)化的情況下,這些數(shù)據(jù)都是可以強行遍歷計算的,而實際上對于現(xiàn)代瀏覽器來說,執(zhí)行加法計算需要的性能消耗并不是很高,例如我們實現(xiàn)1萬次加法運算,實際上的時間消耗也只有不到1ms。
console.time("addition time");
let count = 0;
for (let i = 0; i < 10000; i++) {
count = count + i;
}
console.log(count);
console.timeEnd("addition time"); // 0.64306640625 ms那么接下來我們就以遍歷的方式粗暴地計算我們所需要的數(shù)據(jù),在最后我們會聊一聊基本的優(yōu)化方案。首先我們仍然需要記錄高度,因為節(jié)點并不一定會存在視圖中,所以最開始我們以基本占位高度存儲,當節(jié)點實際渲染之后,我們再更新節(jié)點高度。
// packages/dynamic-height-scroll/src/index.tsx
const heightTable = useMemo(() => {
return Array.from({ length: list.length }, () => DEFAULT_HEIGHT);
}, [list]);
componentDidMount(): void {
const el = this.ref.current;
if (!el) return void 0;
const rect = el.getBoundingClientRect();
this.props.heightTable[this.props.index] = rect.height;
}還記得之前我們聊到的buffer嘛,在IntersectionObserver中提供了rootMargin配置來維護視口的buffer,而在OnScroll中我們需要自行維護,所以在這里我們需要設置一個buffer變量,當滾動容器被實際創(chuàng)建之后我們來更新這個buffer的值以及滾動容器。
const [scroll, setScroll] = useState<HTMLDivElement | null>(null);
const buffer = useRef(0);
const onUpdateInformation = (el: HTMLDivElement) => {
if (!el) return void 0;
buffer.current = el.clientHeight / 2;
setScroll(el);
Promise.resolve().then(onScroll.run);
};
return (
<div
style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
ref={onUpdateInformation}
>
{/* ... */}
</div>
);接下來我們來處理兩個占位塊,在這里沒有使用translateY來做整體偏移,而是直接使用占位塊的方式來撐起滾動區(qū)域,那么此時我們就需要根據(jù)首尾游標來計算具體占位,實際上這里就是之前我們說的萬次加法計算的時間消耗問題,在這里我們直接遍歷計算高度。
const startPlaceHolderHeight = useMemo(() => {
return heightTable.slice(0, start).reduce((a, b) => a + b, 0);
}, [heightTable, start]);
const endPlaceHolderHeight = useMemo(() => {
return heightTable.slice(end, heightTable.length).reduce((a, b) => a + b, 0);
}, [end, heightTable]);
return (
<div
style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
onScroll={onScroll.run}
ref={onUpdateInformation}
>
<div data-index={`0-${start}`} style={{ height: startPlaceHolderHeight }}></div>
{/* ... */}
<div data-index={`${end}-${list.length}`} style={{ height: endPlaceHolderHeight }}></div>
</div>
);那么接下來就需要我們在OnScroll事件中處理我們需要渲染的節(jié)點內容,實際上主要是處理首尾的游標位置,對于首部游標我們直接根據(jù)滾動的高度來計算即可,遍歷到首個節(jié)點的高度大于滾動高度時,我們就可以認為此時的游標就是我們需要渲染的首個節(jié)點,而對于尾部游標我們需要根據(jù)首部游標以及滾動容器的高度來計算,同樣也是遍歷到超出滾動容器高度的節(jié)點時,我們就可以認為此時的游標就是我們需要渲染的尾部節(jié)點。當然,在這游標的計算中別忘了我們的buffer數(shù)據(jù),這是盡量避免滾動時出現(xiàn)空白區(qū)域的關鍵。
const getStartIndex = (top: number) => {
const topStart = top - buffer.current;
let count = 0;
let index = 0;
while (count < topStart) {
count = count + heightTable[index];
index++;
}
return index;
};
const getEndIndex = (clientHeight: number, startIndex: number) => {
const topEnd = clientHeight + buffer.current;
let count = 0;
let index = startIndex;
while (count < topEnd) {
count = count + heightTable[index];
index++;
}
return index;
};
const onScroll = useThrottleFn(
() => {
if (!scroll) return void 0;
const scrollTop = scroll.scrollTop;
const clientHeight = scroll.clientHeight;
const startIndex = getStartIndex(scrollTop);
const endIndex = getEndIndex(clientHeight, startIndex);
setStart(startIndex);
setEnd(endIndex);
},
{ wait: 17 }
);因為我想聊的是虛擬滾動最基本的原理,所以在這里的示例中基本沒有什么優(yōu)化,顯而易見的是我們對于高度的遍歷處理是比較低效的,即使進行萬次加法計算的消耗并不大,但是在大型應用中還是應該盡量避免做如此大量的計算。那么顯而易見的一個優(yōu)化方向是我們可以實現(xiàn)高度的緩存,簡單來說就是對于已經計算過的高度我們可以緩存下來,這樣在下次計算時就可以直接使用緩存的高度,而不需要再次遍歷計算,而出現(xiàn)高度變化需要更新時,我們可以從當前節(jié)點到最新的緩存節(jié)點之間,重新計算緩存高度。而且這種方式相當于是遞增的有序數(shù)組,還可以通過二分等方式解決查找的問題,這樣就可以避免大量的遍歷計算。
height: 10 20 30 40 50 60 ...
cache: 10 30 60 100 150 210 ...
以上就是基于React實現(xiàn)虛擬滾動的方案詳解的詳細內容,更多關于React虛擬滾動的資料請關注腳本之家其它相關文章!
相關文章
React?Native中原生實現(xiàn)動態(tài)導入的示例詳解
在React?Native社區(qū)中,原生動態(tài)導入一直是期待已久的功能,在這篇文章中,我們將比較靜態(tài)和動態(tài)導入,學習如何原生地處理動態(tài)導入,以及有效實施的最佳實踐,希望對大家有所幫助2024-02-02
Zustand介紹與使用 React狀態(tài)管理工具的解決方案
本文主要介紹了Zustand,一種基于React的狀態(tài)管理庫,Zustand以簡潔易用、靈活性高及最小化原則等特點脫穎而出,旨在提供簡單而強大的狀態(tài)管理功能2024-10-10
ReactNative頁面跳轉Navigator實現(xiàn)的示例代碼
本篇文章主要介紹了ReactNative頁面跳轉Navigator實現(xiàn)的示例代碼,具有一定的參考價值,有興趣的可以了解一下2017-08-08
圖文示例講解useState與useReducer性能區(qū)別
這篇文章主要為大家介紹了useState與useReducer性能區(qū)別圖文示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-05-05
解決React報錯Cannot?find?namespace?context
這篇文章主要為大家介紹了React報錯Cannot?find?namespace?context分析解決,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12
React 項目遷移 Webpack Babel7的實現(xiàn)
這篇文章主要介紹了React 項目遷移 Webpack Babel7的實現(xiàn),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-09-09

