基于React實(shí)現(xiàn)虛擬滾動(dòng)的方案詳解
在渲染列表時(shí)我們通常會(huì)一次性將所有列表項(xiàng)渲染到DOM
中,在數(shù)據(jù)量大的時(shí)候這種操作會(huì)造成頁(yè)面響應(yīng)緩慢,因?yàn)闉g覽器需要處理大量的DOM
元素。而此時(shí)我們通常就需要虛擬滾動(dòng)來(lái)實(shí)現(xiàn)性能優(yōu)化,當(dāng)我們擁有大量數(shù)據(jù)需要在用戶界面中以列表或表格的形式展示時(shí),這種性能優(yōu)化方式可以大幅改善用戶體驗(yàn)和應(yīng)用性能,那么在本文中就以固定高度和非固定高度兩種場(chǎng)景展開虛擬滾動(dòng)的實(shí)現(xiàn)。
1.描述
實(shí)現(xiàn)虛擬滾動(dòng)通常并不是非常復(fù)雜的事情,但是我們需要考慮到很多細(xì)節(jié)問(wèn)題。在具體實(shí)現(xiàn)之前我思考了一個(gè)比較有意思的事情,為什么虛擬滾動(dòng)能夠優(yōu)化性能。我們?cè)跒g覽器中進(jìn)行DOM
操作的時(shí)候,此時(shí)這個(gè)DOM
是真正存在的嗎,或者說(shuō)我們?cè)?code>PC上實(shí)現(xiàn)窗口管理的時(shí)候,這個(gè)窗口是真的存在的嗎。那么答案實(shí)際上很明確,這些視圖、窗口、DOM
等等都是通過(guò)圖形化模擬出來(lái)的,雖然我們可以通過(guò)系統(tǒng)或者瀏覽器提供的API
來(lái)非常簡(jiǎn)單地實(shí)現(xiàn)各種操作,但是實(shí)際上些內(nèi)容是系統(tǒng)幫我們繪制出來(lái)的圖像,本質(zhì)上還是通過(guò)外部輸入設(shè)備產(chǎn)生各種事件信號(hào),從而產(chǎn)生狀態(tài)與行為模擬,諸如碰撞檢測(cè)等等都是系統(tǒng)通過(guò)大量計(jì)算表現(xiàn)出的狀態(tài)而已。
那么緊接著,在前段時(shí)間我想學(xué)習(xí)下Canvas
的基本操作,于是我實(shí)現(xiàn)了一個(gè)非?;A(chǔ)的圖形編輯器引擎。因?yàn)樵跒g覽器的Canvas
只提供了最基本的圖形操作,沒(méi)有那么方便的DOM
操作從而所有的交互事件都需要通過(guò)鼠標(biāo)與鍵盤事件自行模擬,這其中有一個(gè)非常重要的點(diǎn)是判斷兩個(gè)圖形是否相交,從而決定是否需要按需重新繪制這個(gè)圖形來(lái)提升性能。
那么我們?cè)O(shè)想一下,最簡(jiǎn)單的判斷方式就是遍歷一遍所有圖形,從而判斷是否與即將要刷新的圖形相交,那么這其中就可能涉及比較復(fù)雜的計(jì)算,而如果我們能夠提前判斷某些圖形是不可能相交的話,就能夠省去很多不必要的計(jì)算。那么在視口外的圖層就是類似的情況,如果我們能夠確定這個(gè)圖形是視口外的,我們就不需要判斷其相交性,而且本身其也不需要渲染,那么虛擬滾動(dòng)也是一樣,如果我們能夠減少DOM
的數(shù)量就能夠減少很多計(jì)算,從而提升整個(gè)頁(yè)面的運(yùn)行時(shí)性能,至于首屏性能就自不必多說(shuō),減少了DOM
數(shù)量首屏的繪制一定會(huì)變快。
當(dāng)然上邊只是我對(duì)于提升頁(yè)面交互或者說(shuō)運(yùn)行時(shí)性能的思考,實(shí)際上關(guān)于虛擬滾動(dòng)優(yōu)化性能的點(diǎn)在社區(qū)上有很多討論了。諸如減少DOM
數(shù)量可以減少瀏覽器需要渲染和維持的DOM
元素?cái)?shù)量,進(jìn)而內(nèi)存占用也隨之減少,這使得瀏覽器可以更快地響應(yīng)用戶操作。以及瀏覽器的reflow
和重繪repaint
操作通常是需要大量計(jì)算的,并且隨著DOM
元素的增多而變得更加頻繁和復(fù)雜,通過(guò)虛擬滾動(dòng)個(gè)減少需要管理的DOM
數(shù)量,同樣可顯著提高渲染性能。此
外虛擬滾動(dòng)還有更快的首屏渲染時(shí)間,特別是超大列表的全量渲染很容易導(dǎo)致首屏渲染時(shí)間過(guò)長(zhǎng),還能夠減少React
維護(hù)組件狀態(tài)所帶來(lái)的Js
性能消耗,特別是在存在Context
的情況下,不特別關(guān)注就可能會(huì)存在性能劣化問(wèn)題。
文中會(huì)提到4
種虛擬滾動(dòng)的實(shí)現(xiàn)方式,分別有固定高度的OnScroll
實(shí)現(xiàn)和不定高度的IntersectionObserver+OnScroll
實(shí)現(xiàn),相關(guān)DEMO
都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/react-virtual-list
中。
2.固定高度
實(shí)際上關(guān)于虛擬滾動(dòng)的方案在社區(qū)有很多參考,特別是固定高度的虛擬滾動(dòng)實(shí)際上可以做成非常通用的解決方案。那么在這里我們以ArcoDesign
的List
組件為例來(lái)研究一下通用的虛擬滾動(dòng)實(shí)現(xiàn)。在Arco
給予的示例中我們可以看到其傳遞了height
屬性,此時(shí)如果我們將這個(gè)屬性刪除的話虛擬滾動(dòng)是無(wú)法正常啟動(dòng)的。
那么實(shí)際上Arco
就是通過(guò)列表元素的數(shù)量與每個(gè)元素的高度,從而計(jì)算出了整個(gè)容器的高度,這里要注意滾動(dòng)容器實(shí)際上應(yīng)該是虛擬滾動(dòng)的容器外的元素,而對(duì)于視口內(nèi)的區(qū)域則可以通過(guò)transform: translateY(Npx)
來(lái)做實(shí)際偏移。當(dāng)我們滾動(dòng)的時(shí)候,我們需要通過(guò)滾動(dòng)條的實(shí)際滾動(dòng)距離以及滾動(dòng)容器的高度,配合我們配置的元素實(shí)際高度,就可以計(jì)算出來(lái)當(dāng)前視口實(shí)際需要渲染的節(jié)點(diǎn),而其他的節(jié)點(diǎn)并不實(shí)際渲染,從而實(shí)現(xiàn)虛擬滾動(dòng)。當(dāng)然實(shí)際上關(guān)于Arco
虛擬滾動(dòng)的配置還有很多,在這里就不完整展開了。
<List {/* ... */} virtualListProps={{ height: 560, }} {/* ... */} />
那么我們可以先來(lái)設(shè)想一下,當(dāng)我們有了每個(gè)元素的高度以及元素?cái)?shù)量,很明顯我們就可以計(jì)算出容器的高度了,當(dāng)我們有了容器的高度,此時(shí)滾動(dòng)容器的子元素就可以得到,此時(shí)我們就可以得到擁有滾動(dòng)條的滾動(dòng)容器了。
// 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>
那么既然滾動(dòng)容器已經(jīng)有了,我們現(xiàn)在就需要關(guān)注于我們即將要展示的列表元素,因?yàn)槲覀兪谴嬖跐L動(dòng)條且實(shí)際有滾動(dòng)偏移的,所以我們的滾動(dòng)條位置需要鎖定在我們的視口位置上。我們只需要使用scrollTop / itemHeight
取整即可,并且這里我們使用translateY
來(lái)做整體偏移,使用translate
還可以觸發(fā)硬件加速。那么除了列表的整體偏移之外,我們還需要計(jì)算出當(dāng)前視口內(nèi)的元素?cái)?shù)量,這里的計(jì)算同樣非常簡(jiǎn)單,因?yàn)槲覀兊母叨裙潭耍藭r(shí)只需要跟滾動(dòng)容器相除即可,實(shí)際上這部分在實(shí)例化組件的時(shí)候就已經(jīng)完成了。
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.動(dòng)態(tài)高度
固定高度的虛擬滾動(dòng)是比較適用于通用的場(chǎng)景的,實(shí)際上此處的固定高度不一定是指元素的高度是固定的,而是指元素的高度是可以直接計(jì)算得到而不是必須要渲染之后才能得到,例如圖片的寬高是可以在上傳時(shí)保存,然后在渲染時(shí)通過(guò)圖片寬高以及容器寬度計(jì)算得到的。然而實(shí)際上我們有很多場(chǎng)景下并不臺(tái)能夠完全做到元素的固定高度,例如在線文檔場(chǎng)景下的富文本編輯器中,特別是文本塊的高度,在不同的字體、瀏覽器寬度等情況下表現(xiàn)是不同的。
我們無(wú)法在其渲染之前的到其高度,這就導(dǎo)致了我們無(wú)法像圖片一樣提前計(jì)算出其占位高度,從而對(duì)于文檔塊結(jié)構(gòu)的虛擬滾動(dòng)就必須要解決塊高度不固定的問(wèn)題,由此我們需要實(shí)現(xiàn)動(dòng)態(tài)高度的虛擬滾動(dòng)調(diào)度策略來(lái)處理這個(gè)問(wèn)題。
3.1IntersectionObserver占位符
如果我們需要判斷元素是否出現(xiàn)在視口當(dāng)中時(shí),通常會(huì)監(jiān)聽(tīng)onScroll
事件用來(lái)判斷元素實(shí)際位置,而現(xiàn)如今絕大多數(shù)瀏覽器都提供了IntersectionObserver
原生對(duì)象,用以異步地觀察目標(biāo)元素與其祖先元素或頂級(jí)文檔視口的交叉狀態(tài),這對(duì)判斷元素是否出現(xiàn)在視口范圍非常有用,那么同樣的,我們也可以借助IntersectionObserver
來(lái)實(shí)現(xiàn)虛擬滾動(dòng)。
需要注意的是,IntersectionObserver
對(duì)象的應(yīng)用場(chǎng)景是觀察目標(biāo)元素與視口的交叉狀態(tài),而我們的虛擬滾動(dòng)核心概念是不渲染非視口區(qū)域的元素,所以這里邊實(shí)際上出現(xiàn)了一個(gè)偏差,在虛擬滾動(dòng)中目標(biāo)元素都不存在或者說(shuō)并未渲染,那么此時(shí)是無(wú)法觀察其狀態(tài)的。所以為了配合IntersectionObserver
的概念,我們需要渲染實(shí)際的占位符,例如10k
個(gè)列表的節(jié)點(diǎn),我們首先就需要渲染10k
個(gè)占位符,實(shí)際上這也是一件合理的事,除非我們最開始就注意到列表的性能問(wèn)題,而實(shí)際上大部分都是后期優(yōu)化頁(yè)面性能,特別是在復(fù)雜的場(chǎng)景下例如文檔中,所以假設(shè)原本有1w
條數(shù)據(jù),每條數(shù)據(jù)即使僅渲染3
個(gè)節(jié)點(diǎn),那么此時(shí)我們?nèi)绻麅H渲染占位符的情況下還能將原本頁(yè)面30k
個(gè)節(jié)點(diǎn)優(yōu)化到大概10k
個(gè)節(jié)點(diǎn),這對(duì)于性能提升本身也是非常有意義的。
此外,在https://caniuse.com/?search=IntersectionObserver
可以觀察到兼容性還是不錯(cuò)的,在瀏覽器不支持的情況下可以采用OnScroll
方案或者考慮使用polyfill
。那么緊接著,我們來(lái)實(shí)現(xiàn)這部分內(nèi)容,首先我們需要生成數(shù)據(jù),在這里需要注意的是我們所說(shuō)的不定高度實(shí)際上應(yīng)該是被稱為動(dòng)態(tài)高度,元素的高度是需要我們實(shí)際渲染之后才能得到的,在渲染之前我們僅以估算的高度占位,從而能夠使?jié)L動(dòng)容器產(chǎn)生滾動(dòng)效果。
// 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> ), }; });
接下來(lái)我們需要?jiǎng)?chuàng)建IntersectionObserver
,同樣的因?yàn)槲覀兊臐L動(dòng)容器可能并不一定是window
,所以我們需要在滾動(dòng)容器上創(chuàng)建IntersectionObserver
,此外通常我們會(huì)對(duì)視口區(qū)域做一層buffer
,用來(lái)提前加載視口外的元素,這樣可以避免用戶滾動(dòng)時(shí)出現(xiàn)空白區(qū)域,這個(gè)buffer
的大小通常選擇當(dāng)前視口高度的一半。
useLayoutEffect(() => { if (!scroll) return void 0; // 視口閾值 取滾動(dòng)容器高度的一半 const margin = scroll.clientHeight / 2; const current = new IntersectionObserver(onIntersect, { root: scroll, rootMargin: `${margin}px 0px`, }); setObserver(current); return () => { current.disconnect(); }; }, [onIntersect, scroll]);
接下來(lái)我們需要對(duì)占位節(jié)點(diǎn)的狀態(tài)進(jìn)行管理,因?yàn)槲覀兇藭r(shí)有實(shí)際占位,所以就不再需要預(yù)估整個(gè)容器的高度,而且只需要實(shí)際滾動(dòng)到相關(guān)位置將節(jié)點(diǎn)渲染即可。我們?yōu)楣?jié)點(diǎn)設(shè)置三個(gè)狀態(tài),loading
狀態(tài)即占位狀態(tài),此時(shí)節(jié)點(diǎn)只渲染空的占位符也可以渲染一個(gè)loading
標(biāo)識(shí),此時(shí)我們還不知道這個(gè)節(jié)點(diǎn)的實(shí)際高度;viewport
狀態(tài)即為節(jié)點(diǎn)真實(shí)渲染狀態(tài),也就是說(shuō)節(jié)點(diǎn)在邏輯視口內(nèi),此時(shí)我們可以記錄節(jié)點(diǎn)的真實(shí)高度;placeholder
狀態(tài)為渲染后的占位狀態(tài),相當(dāng)于節(jié)點(diǎn)從在視口內(nèi)滾動(dòng)到了視口外,此時(shí)節(jié)點(diǎn)的高度已經(jīng)被記錄,我們可以將節(jié)點(diǎn)的高度設(shè)置為真實(shí)高度。
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> ); }
當(dāng)然我們的Observer
的觀察同樣需要配置,這里需要注意的是IntersectionObserver
的回調(diào)函數(shù)只會(huì)攜帶target
節(jié)點(diǎn)信息,我們需要通過(guò)節(jié)點(diǎn)信息找到我們實(shí)際的Node
來(lái)管理節(jié)點(diǎn)狀態(tài),所以此處我們借助WeakMap
來(lái)建立元素到節(jié)點(diǎn)的關(guān)系,從而方便我們處理。
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); }
最后就是實(shí)際滾動(dòng)調(diào)度了,當(dāng)節(jié)點(diǎn)出現(xiàn)在視口時(shí)我們需要根據(jù)ELEMENT_TO_NODE
獲取節(jié)點(diǎn)信息,然后根據(jù)當(dāng)前視口信息來(lái)設(shè)置狀態(tài),如果當(dāng)前節(jié)點(diǎn)是進(jìn)入視口的狀態(tài)我們就將節(jié)點(diǎn)狀態(tài)設(shè)置為viewport
,如果此時(shí)是出視口的狀態(tài)則需要二次判斷當(dāng)前狀態(tài),如果不是初始的loading
狀態(tài)則可以直接將高度與placeholder
設(shè)置到節(jié)點(diǎn)狀態(tài)上,此時(shí)節(jié)點(diǎn)的高度就是實(shí)際高度。
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) { // 進(jìn)入視口 node.changeStatus("viewport", rect.height); } else { // 脫離視口 if (node.state.mode !== "loading") { node.changeStatus("placeholder", rect.height); } } }); });
3.2IntersectionObserver虛擬化
在前邊我們也提到了IntersectionObserver
的目標(biāo)是觀察目標(biāo)元素與視口的交叉狀態(tài),而我們的虛擬滾動(dòng)核心概念是不渲染非視口區(qū)域的元素,那么究竟能不能通過(guò)IntersectionObserver
實(shí)現(xiàn)虛擬滾動(dòng)的效果,實(shí)際上是可以的,但是可能需要OnScroll
來(lái)輔助節(jié)點(diǎn)的強(qiáng)制刷新。在這里我們嘗試使用標(biāo)記節(jié)點(diǎn)以及額外渲染的方式來(lái)實(shí)現(xiàn)虛擬列表,但是要注意的是,在這里因?yàn)闆](méi)有使用OnScroll
來(lái)強(qiáng)制刷新節(jié)點(diǎn),當(dāng)快速滾動(dòng)的時(shí)候可能會(huì)出現(xiàn)空白的情況。
在先前的占位方案中,我們已經(jīng)實(shí)現(xiàn)了IntersectionObserver
的基本操作,在這里就不再贅述了。而在這里我們的核心思路是標(biāo)記虛擬列表節(jié)點(diǎn)的首位,并且節(jié)點(diǎn)的首尾是額外渲染的,相當(dāng)于首尾節(jié)點(diǎn)是在視口外的節(jié)點(diǎn),當(dāng)首尾節(jié)點(diǎn)的狀態(tài)發(fā)生改變時(shí),我們可以通過(guò)回調(diào)函數(shù)來(lái)控制其首尾的指針?lè)秶?,從而?shí)現(xiàn)虛擬滾動(dòng)。那么在這之前,我們需要先控制好首尾指針的狀態(tài),避免出現(xiàn)負(fù)值或者越界的情況。
// 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)); } });
緊接著我們還需要兩個(gè)數(shù)組,分別用來(lái)管理所有的節(jié)點(diǎn)以及節(jié)點(diǎn)的高度值,因?yàn)榇藭r(shí)我們的節(jié)點(diǎn)可能是不存在的,所以其狀態(tài)與高度需要額外的變量來(lái)管理,并且我們還需要兩個(gè)占位塊來(lái)作為首尾節(jié)點(diǎn)的占位,用來(lái)實(shí)現(xiàn)在滾動(dòng)容器中滾動(dòng)的效果。占位塊同樣需要對(duì)其進(jìn)行觀察,并且其高度就需要根據(jù)高度值的節(jié)點(diǎn)計(jì)算,當(dāng)然這部分計(jì)算寫的比較粗暴,還有很大的優(yōu)化空間,例如額外維護(hù)一個(gè)單調(diào)遞增的隊(duì)列來(lái)計(jì)算高度。
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é)點(diǎn)渲染時(shí),我們需要標(biāo)記其狀態(tài),這里的Node
節(jié)點(diǎn)的數(shù)據(jù)會(huì)變得更多,在這里主要是需要標(biāo)注isFirstNode
、isLastNode
兩個(gè)狀態(tài),并且initHeight
需要從外部傳遞,之前也提到過(guò)了,節(jié)點(diǎn)可能不存在,此時(shí)如果再?gòu)念^加載的話高度會(huì)不正確,倒是滾動(dòng)不流暢的問(wèn)題,所以我們需要在節(jié)點(diǎn)渲染時(shí)傳遞initHeight
,這個(gè)高度值就是節(jié)點(diǎn)渲染記錄的實(shí)際高度或者未渲染過(guò)的占位高度。
<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>
還有一個(gè)需要關(guān)注的問(wèn)題是視口鎖定,當(dāng)在可見(jiàn)區(qū)域之外的節(jié)點(diǎn)高度發(fā)生變化時(shí),如果不進(jìn)行視口鎖定,就會(huì)出現(xiàn)可視區(qū)域跳變的問(wèn)題。這里還需要注意的是我們不能使用smooth
滾動(dòng)的動(dòng)畫表現(xiàn),如果使用動(dòng)畫的話可能會(huì)導(dǎo)致滾動(dòng)的過(guò)程中其他節(jié)點(diǎn)高度變更且視口鎖定失效的情況,此時(shí)依然會(huì)導(dǎo)致視口區(qū)域跳變,我們必須明確地指定滾動(dòng)的位置,如果實(shí)在需要?jiǎng)赢嫷脑挘瑯右残枰ㄟ^(guò)明確的數(shù)值緩慢遞增來(lái)模擬,而不是直接使用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; } };
接下來(lái)就是重點(diǎn)的回調(diào)函數(shù)處理了,這里涉及到比較復(fù)雜的狀態(tài)管理。首先是兩個(gè)占位節(jié)點(diǎn),當(dāng)兩個(gè)占位節(jié)點(diǎn)出現(xiàn)在視口時(shí),我們認(rèn)為此時(shí)是需要加載其他節(jié)點(diǎn)的,以起始占位節(jié)點(diǎn)為例,當(dāng)其出現(xiàn)在視口時(shí),我們需要將起始指針前移,而前移的數(shù)量需要根據(jù)實(shí)際視口 交叉的范圍計(jì)算。
const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0; if (entry.target === startPlaceHolder.current) { // 起始占位符進(jìn)入視口 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) { // 結(jié)束占位符進(jìn)入視口 if (isIntersecting && entry.target.clientHeight > 0) { // .... setSafeEnd(end => end + increment); } return void 0; }
接下來(lái)跟占位方案一樣,我們同樣需要根據(jù)ELEMENT_TO_NODE
來(lái)獲取節(jié)點(diǎn)信息,然后此時(shí)需要更新我們的高度記錄變量。由于我們?cè)?code>IntersectionObserver回調(diào)中無(wú)法判斷實(shí)際滾動(dòng)方向,也不容易判斷實(shí)際滾動(dòng)范圍,所以此時(shí)我們需要根據(jù)之前提到的isFirstNode
與isLastNode
信息來(lái)控制首尾游標(biāo)指針。FirstNode
進(jìn)入視口認(rèn)為是向下滾動(dòng),此時(shí)需要將上方范圍的節(jié)點(diǎn)渲染出來(lái),而LastNode
進(jìn)入視口認(rèn)為是向上滾動(dòng),此時(shí)需要將下方范圍的節(jié)點(diǎn)渲染出來(lái)。FirstNode
脫離視口認(rèn)為是向上滾動(dòng),此時(shí)需要將上方范圍的節(jié)點(diǎn)移除,而LastNode
脫離視口認(rèn)為是向下滾動(dòng),此時(shí)需要將下方范圍的節(jié)點(diǎn)移除。這里可以注意到我們?cè)黾庸?jié)點(diǎn)范圍使用的是THRESHOLD
,而減少節(jié)點(diǎn)范圍使用的是1
,這里就是我們需要額外渲染的首尾節(jié)點(diǎn)。
const node = ELEMENT_TO_NODE.get(entry.target); const rect = entry.boundingClientRect; record[node.props.index] = rect.height; if (isIntersecting) { // 進(jìn)入視口 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); } }
在最后,因?yàn)檫@個(gè)狀態(tài)很難控制的比較完善,我們還需要為其做兜底處理,防止頁(yè)面上遺留過(guò)多節(jié)點(diǎn)。當(dāng)然實(shí)際上即使遺留了節(jié)點(diǎn)也沒(méi)有問(wèn)題,相當(dāng)于降級(jí)到了我們上邊提到的占位方案,實(shí)際上并不會(huì)出現(xiàn)大量的節(jié)點(diǎn),相當(dāng)于在這里實(shí)現(xiàn)的是懶加載的占位節(jié)點(diǎn)。不過(guò)我們?cè)谶@里依然給予了處理方案,可以通過(guò)節(jié)點(diǎn)狀態(tài)來(lái)標(biāo)識(shí)節(jié)點(diǎn)是否是作為分界線需要實(shí)際處理為首尾游標(biāo)邊界。
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滾動(dòng)事件監(jiān)聽(tīng)
那么實(shí)現(xiàn)動(dòng)態(tài)高度的虛擬滾動(dòng),我們也不能忘記常用的OnScroll
方案,實(shí)際上相對(duì)于使用IntersectionObserver
來(lái)說(shuō),單純的虛擬滾動(dòng)OnScroll
方案更加簡(jiǎn)單,當(dāng)然同樣的也更加容易出現(xiàn)性能問(wèn)題。使用OnScroll
的核心思路同樣是需要一個(gè)滾動(dòng)容器,然后我們需要監(jiān)聽(tīng)滾動(dòng)事件,當(dāng)滾動(dòng)事件觸發(fā)時(shí),我們需要根據(jù)滾動(dòng)的位置來(lái)計(jì)算當(dāng)前視口內(nèi)的節(jié)點(diǎn),然后根據(jù)節(jié)點(diǎn)的高度來(lái)計(jì)算實(shí)際需要渲染的節(jié)點(diǎn),從而實(shí)現(xiàn)虛擬滾動(dòng)。
那么動(dòng)態(tài)高度的虛擬滾動(dòng)與最開始我們實(shí)現(xiàn)的固定高度的虛擬滾動(dòng)區(qū)別在哪,首先是滾動(dòng)容器的高度,我們?cè)谧铋_始不能夠知道滾動(dòng)容器實(shí)際有多高,而是在不斷渲染的過(guò)程中才能知道實(shí)際高度;其次我們不能直接根據(jù)滾動(dòng)的高度計(jì)算出當(dāng)前需要渲染的節(jié)點(diǎn),在之前我們渲染的起始index
游標(biāo)是直接根據(jù)滾動(dòng)容器高度和列表所有節(jié)點(diǎn)總高度算出來(lái)的,而在動(dòng)態(tài)高度的虛擬滾動(dòng)中,我們無(wú)法獲得總高度,同樣的渲染節(jié)點(diǎn)的長(zhǎng)度也是如此,我們無(wú)法得知本次渲染究竟需要渲染多少節(jié)點(diǎn);再有我們不容易判斷節(jié)點(diǎn)距離滾動(dòng)容器頂部的高度,也就是之前我們提到的translateY
,我們需要使用這個(gè)高度來(lái)?yè)纹饾L動(dòng)的區(qū)域,從而讓我們能夠?qū)嶋H做到滾動(dòng)。
那么我們說(shuō)的這些數(shù)值都是無(wú)法計(jì)算的嘛,顯然不是這樣的,在我們沒(méi)有任何優(yōu)化的情況下,這些數(shù)據(jù)都是可以強(qiáng)行遍歷計(jì)算的,而實(shí)際上對(duì)于現(xiàn)代瀏覽器來(lái)說(shuō),執(zhí)行加法計(jì)算需要的性能消耗并不是很高,例如我們實(shí)現(xiàn)1
萬(wàn)次加法運(yùn)算,實(shí)際上的時(shí)間消耗也只有不到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
那么接下來(lái)我們就以遍歷的方式粗暴地計(jì)算我們所需要的數(shù)據(jù),在最后我們會(huì)聊一聊基本的優(yōu)化方案。首先我們?nèi)匀恍枰涗浉叨?,因?yàn)楣?jié)點(diǎn)并不一定會(huì)存在視圖中,所以最開始我們以基本占位高度存儲(chǔ),當(dāng)節(jié)點(diǎn)實(shí)際渲染之后,我們?cè)俑鹿?jié)點(diǎn)高度。
// 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
配置來(lái)維護(hù)視口的buffer
,而在OnScroll
中我們需要自行維護(hù),所以在這里我們需要設(shè)置一個(gè)buffer
變量,當(dāng)滾動(dòng)容器被實(shí)際創(chuàng)建之后我們來(lái)更新這個(gè)buffer
的值以及滾動(dòng)容器。
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> );
接下來(lái)我們來(lái)處理兩個(gè)占位塊,在這里沒(méi)有使用translateY
來(lái)做整體偏移,而是直接使用占位塊的方式來(lái)?yè)纹饾L動(dòng)區(qū)域,那么此時(shí)我們就需要根據(jù)首尾游標(biāo)來(lái)計(jì)算具體占位,實(shí)際上這里就是之前我們說(shuō)的萬(wàn)次加法計(jì)算的時(shí)間消耗問(wèn)題,在這里我們直接遍歷計(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> );
那么接下來(lái)就需要我們?cè)?code>OnScroll事件中處理我們需要渲染的節(jié)點(diǎn)內(nèi)容,實(shí)際上主要是處理首尾的游標(biāo)位置,對(duì)于首部游標(biāo)我們直接根據(jù)滾動(dòng)的高度來(lái)計(jì)算即可,遍歷到首個(gè)節(jié)點(diǎn)的高度大于滾動(dòng)高度時(shí),我們就可以認(rèn)為此時(shí)的游標(biāo)就是我們需要渲染的首個(gè)節(jié)點(diǎn),而對(duì)于尾部游標(biāo)我們需要根據(jù)首部游標(biāo)以及滾動(dòng)容器的高度來(lái)計(jì)算,同樣也是遍歷到超出滾動(dòng)容器高度的節(jié)點(diǎn)時(shí),我們就可以認(rèn)為此時(shí)的游標(biāo)就是我們需要渲染的尾部節(jié)點(diǎn)。當(dāng)然,在這游標(biāo)的計(jì)算中別忘了我們的buffer
數(shù)據(jù),這是盡量避免滾動(dòng)時(shí)出現(xiàn)空白區(qū)域的關(guān)鍵。
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àn)槲蚁肓牡氖翘摂M滾動(dòng)最基本的原理,所以在這里的示例中基本沒(méi)有什么優(yōu)化,顯而易見(jiàn)的是我們對(duì)于高度的遍歷處理是比較低效的,即使進(jìn)行萬(wàn)次加法計(jì)算的消耗并不大,但是在大型應(yīng)用中還是應(yīng)該盡量避免做如此大量的計(jì)算。那么顯而易見(jiàn)的一個(gè)優(yōu)化方向是我們可以實(shí)現(xiàn)高度的緩存,簡(jiǎn)單來(lái)說(shuō)就是對(duì)于已經(jīng)計(jì)算過(guò)的高度我們可以緩存下來(lái),這樣在下次計(jì)算時(shí)就可以直接使用緩存的高度,而不需要再次遍歷計(jì)算,而出現(xiàn)高度變化需要更新時(shí),我們可以從當(dāng)前節(jié)點(diǎn)到最新的緩存節(jié)點(diǎn)之間,重新計(jì)算緩存高度。而且這種方式相當(dāng)于是遞增的有序數(shù)組,還可以通過(guò)二分等方式解決查找的問(wèn)題,這樣就可以避免大量的遍歷計(jì)算。
height: 10 20 30 40 50 60 ...
cache: 10 30 60 100 150 210 ...
以上就是基于React實(shí)現(xiàn)虛擬滾動(dòng)的方案詳解的詳細(xì)內(nèi)容,更多關(guān)于React虛擬滾動(dòng)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
React?Native中原生實(shí)現(xiàn)動(dòng)態(tài)導(dǎo)入的示例詳解
在React?Native社區(qū)中,原生動(dòng)態(tài)導(dǎo)入一直是期待已久的功能,在這篇文章中,我們將比較靜態(tài)和動(dòng)態(tài)導(dǎo)入,學(xué)習(xí)如何原生地處理動(dòng)態(tài)導(dǎo)入,以及有效實(shí)施的最佳實(shí)踐,希望對(duì)大家有所幫助2024-02-02Zustand介紹與使用 React狀態(tài)管理工具的解決方案
本文主要介紹了Zustand,一種基于React的狀態(tài)管理庫(kù),Zustand以簡(jiǎn)潔易用、靈活性高及最小化原則等特點(diǎn)脫穎而出,旨在提供簡(jiǎn)單而強(qiáng)大的狀態(tài)管理功能2024-10-10Reactjs?錯(cuò)誤邊界優(yōu)雅處理方法demo
這篇文章主要為大家介紹了Reactjs?錯(cuò)誤邊界優(yōu)雅處理方法demo,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12ReactNative頁(yè)面跳轉(zhuǎn)Navigator實(shí)現(xiàn)的示例代碼
本篇文章主要介紹了ReactNative頁(yè)面跳轉(zhuǎn)Navigator實(shí)現(xiàn)的示例代碼,具有一定的參考價(jià)值,有興趣的可以了解一下2017-08-08圖文示例講解useState與useReducer性能區(qū)別
這篇文章主要為大家介紹了useState與useReducer性能區(qū)別圖文示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-05-05解決React報(bào)錯(cuò)Cannot?find?namespace?context
這篇文章主要為大家介紹了React報(bào)錯(cuò)Cannot?find?namespace?context分析解決,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12React 項(xiàng)目遷移 Webpack Babel7的實(shí)現(xiàn)
這篇文章主要介紹了React 項(xiàng)目遷移 Webpack Babel7的實(shí)現(xiàn),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-09-09