網(wǎng)站首頁(yè) 編程語(yǔ)言 正文
大家好,我是前端西瓜哥。這次我們來(lái)看看虛擬列表是什么玩意,并用 React 來(lái)實(shí)現(xiàn)兩種虛擬列表組件。
虛擬列表,其實(shí)就是將一個(gè)原本需要全部列表項(xiàng)的渲染的長(zhǎng)列表,改為只渲染可視區(qū)域內(nèi)的列表項(xiàng),但滾動(dòng)效果還是要和渲染所有列表項(xiàng)的長(zhǎng)列表一樣。
虛擬列表解決的長(zhǎng)列表渲染大量節(jié)點(diǎn)導(dǎo)致的性能問(wèn)題:
- 一次性渲染大量節(jié)點(diǎn),會(huì)占用大量 GP 資源,導(dǎo)致卡頓;
- 即使渲染好了,大量的節(jié)點(diǎn)也持續(xù)占用內(nèi)存。列表項(xiàng)下的節(jié)點(diǎn)越多,就越耗費(fèi)性能。
虛擬列表的實(shí)現(xiàn)分兩種,一種是列表項(xiàng)高度固定的情況,另一種是列表項(xiàng)高度動(dòng)態(tài)的情況。
列表項(xiàng)高度固定
列表項(xiàng)高度固定的情況會(huì)簡(jiǎn)單很多,因?yàn)槲覀兛梢栽阡秩厩熬湍苤廊魏我粋€(gè)列表項(xiàng)的位置。
因?yàn)樯婕暗降淖兞亢芏啵瑢?shí)現(xiàn)起來(lái)還是有點(diǎn)繁瑣。
我們需要的必要信息有:
- 容器高度(即可視區(qū)域高度) containerHeight
- 列表長(zhǎng)度(即列表項(xiàng)總數(shù)) itemCount
- 列表項(xiàng)尺寸 itemHeight
- 滾動(dòng)位置 scrollTop
虛擬列表通常來(lái)說(shuō)是垂直方向的,但偶爾也有水平方向的場(chǎng)景,所以如果你要實(shí)現(xiàn)一個(gè)廣泛適用的組件,理論上應(yīng)該用 size 而不是 height,前者語(yǔ)義更好。
但為了減少用戶的思維轉(zhuǎn)換導(dǎo)致的負(fù)擔(dān),本文會(huì)使用 height 來(lái)表示一個(gè)列表項(xiàng)的高度。
要讓表單項(xiàng)渲染在正確位置,我們有幾種方案:
- 在容器的第一個(gè)元素用一個(gè)空元素,設(shè)置一個(gè)高度,將需要顯示在可視區(qū)域的 items 往下推到正確位置。我嘗試著實(shí)現(xiàn)了,發(fā)現(xiàn)滾動(dòng)快一點(diǎn)就會(huì)有閃屏現(xiàn)象。
- 將需要渲染的元素一個(gè) div 包裹起來(lái),對(duì)這個(gè) div 應(yīng)用?
transform: translate3d(0px, 1000px, 0px);
- 對(duì)每個(gè)列表項(xiàng)使用絕對(duì)定位(或 transform)
這里我們選擇第一個(gè)方案來(lái)進(jìn)行實(shí)現(xiàn)。
代碼實(shí)現(xiàn)
這里我先給出代碼實(shí)現(xiàn)。
我們實(shí)現(xiàn)了一個(gè) FixedSizeList 的 React 組件。
它接收一個(gè)上面提到的幾個(gè)數(shù)量和高度參數(shù)外,還接收一個(gè)列表項(xiàng)組件。
我們會(huì)將計(jì)算出來(lái)的高度做成 style 對(duì)象以及一個(gè)索引值 index傳入到這個(gè)組件里進(jìn)行實(shí)例化。所以記得在列表項(xiàng)組件內(nèi)接收它們并使用上它們,尤其是 style。
/** ?*?一個(gè)將?items?往下推到正確位置的空元素 ?*/ import?{?useState?}?from?'react'; import?{?flushSync?}?from?'react-dom'; function?FixedSizeList({?containerHeight,?itemHeight,?itemCount,?children?})?{ ??//?children?語(yǔ)義不好,賦值給?Component ??const?Component?=?children; ??const?contentHeight?=?itemHeight?*?itemCount;?//?內(nèi)容總高度 ??const?[scrollTop,?setScrollTop]?=?useState(0);?//?滾動(dòng)位置 ??//?繼續(xù)需要渲染的?item?索引有哪些 ??let?startIdx?=?Math.floor(scrollTop?/?itemHeight); ??let?endIdx?=?Math.floor((scrollTop?+?containerHeight)?/?itemHeight); ??//?上下額外多渲染幾個(gè)?item,解決滾動(dòng)時(shí)來(lái)不及加載元素出現(xiàn)短暫的空白區(qū)域的問(wèn)題 ??const?paddingCount?=?2; ??startIdx?=?Math.max(startIdx?-?paddingCount,?0);?//?處理越界情況 ??endIdx?=?Math.min(endIdx?+?paddingCount,?itemCount?-?1); ??const?top?=?itemHeight?*?startIdx;?//?第一個(gè)渲染的?item?到頂部距離 ??//?需要渲染的?items ??const?items?=?[]; ??for?(let?i?=?startIdx;?i?<=?endIdx;?i++)?{ ????items.push(<Component?key={i}?index={i}?style={{?height:?itemHeight?}}?/>); ??} ??return?( ????<div ??????style={{?height:?containerHeight,?overflow:?'auto'?}} ??????onScroll={(e)?=>?{ ????????//?處理渲染異步導(dǎo)致的空白現(xiàn)象 ????????//?改為同步更新,但可能會(huì)有性能問(wèn)題,可以做?節(jié)流?+?RAF?優(yōu)化 ????????flushSync(()?=>?{ ??????????setScrollTop(e.target.scrollTop); ????????}); ??????}} ????> ??????<div?style={{?height:?contentHeight?}}> ????????{/*?一個(gè)將?items?往下推到正確位置的空元素?*/} ????????<div?style={{?height:?top?}}></div> ????????{items} ??????</div> ????</div> ??); }
線上 demo:
https://codesandbox.io/s/jhe2rt
效果:
首先我們需要知道?渲染的節(jié)點(diǎn)的索引值范圍。
//?計(jì)算需要渲染的?item?范圍 let?startIdx?=?Math.floor(scrollTop?/?itemHeight); let?endIdx?=?Math.floor((scrollTop?+?containerHeight)?/?itemHeight);
首先算第一個(gè) item 的位置 startIdx。
我們用 scrollTop 除以列表項(xiàng)高度 itemHeight,我們就知道 scrollTop 經(jīng)過(guò)了多個(gè) item,將得到的結(jié)果向下取整就是可視區(qū)域中的第一個(gè) item。最后一個(gè)索引值 endidx 計(jì)算同理。
有時(shí)候我們希望上下方向再多渲染幾個(gè) item(緩解在做節(jié)流時(shí)沒(méi)有立即渲染導(dǎo)致的空白現(xiàn)象),我們可以讓范圍往兩邊擴(kuò)展一些,注意不要越界。
//?擴(kuò)展范圍 const?paddingCount?=?2; startIdx?=?Math.max(startIdx?-?paddingCount,?0);?//?處理越界情況 endIdx?=?Math.min(endIdx?+?paddingCount,?itemCount?-?1);
然后基于這個(gè)范圍,對(duì)列表項(xiàng)組件進(jìn)行實(shí)例化。
//?需要渲染的?items const?items?=?[]; for?(let?i?=?startIdx;?i?<=?endIdx;?i++)?{ ??items.push(<Component?key={i}?index={i}?style={{?height:?itemHeight?}}?/>); }
然后是?DOM 結(jié)構(gòu)的說(shuō)明。
<div ??style={{?height:?containerHeight,?overflow:?'auto'?}} ??onScroll={(e)?=>?{ ????//?處理渲染異步導(dǎo)致的空白現(xiàn)象 ????//?改為同步更新,但可能會(huì)有性能問(wèn)題,可以做?節(jié)流?+?RAF?優(yōu)化 ????flushSync(()?=>?{ ??????setScrollTop(e.target.scrollTop); ????}); ??}} > ??<div?style={{?height:?contentHeight?}}> ????{/*?一個(gè)將?items?往下推到正確位置的空元素?*/} ????<div?style={{?height:?top?}}></div> ????{items} ??</div> </div>
最外層是“容器 div”,我們給它的高度設(shè)置傳入的 containerHeight。
接著是“內(nèi)容 div”。contentHeight 由 itemHeight 乘以 itemCount 計(jì)算而來(lái),代表的是所有 item 組成的高度。我們把它放著這里,是為了讓 “容器 div”?產(chǎn)生正確的滾動(dòng)條。
內(nèi)容 div 下是我們的 items,以及開頭的?一個(gè)將 items 往下推到正確位置的空元素,可以看作是一種 padding-top。它的高度值 top 由 itemHeight 乘以 startIdx 計(jì)算而來(lái)。
然后是監(jiān)聽滾動(dòng)事件,當(dāng) scrollTop 改變時(shí),更新組件。我這里使用的是 React18,默認(rèn)是并發(fā)模式,更新狀態(tài) setState 是異步的,因此在快速滾動(dòng)的情況下,會(huì)出現(xiàn)渲染不實(shí)時(shí)導(dǎo)致的短暫空白現(xiàn)象。
所以這里我用了 ReactDOM 的 flushSync 方法,讓狀態(tài)的更新變成同步的,來(lái)解決短暫空白問(wèn)題。
但滾動(dòng)是一個(gè)高頻觸發(fā)的時(shí)間,我的這種寫法在列表項(xiàng)復(fù)雜的情況下,是可能會(huì)出現(xiàn)性能問(wèn)題的。更好的做法是做?函數(shù)節(jié)流 + RAF(requestAnimationFrame),雖然也會(huì)有一些空白現(xiàn)象,但不會(huì)太嚴(yán)重。
列表項(xiàng)高度動(dòng)態(tài)
列表項(xiàng)高度動(dòng)態(tài)的情況,就復(fù)雜得多。
如果能夠?在渲染前知道所有列表項(xiàng)的高度,那實(shí)現(xiàn)思路還是同前面列表項(xiàng)高度固定的情況一致。
只是我們不能用乘法來(lái)計(jì)算了,要改成累加的方式來(lái)計(jì)算 startIdx 和 endIdx。
然而實(shí)際上更常見(jiàn)的情況是列表項(xiàng)?高度根據(jù)內(nèi)容自適應(yīng),只能在渲染完成后才能知道真正高度。
怎么辦呢?通常的方式是?提供一個(gè)列表項(xiàng)預(yù)設(shè)高度,在列表項(xiàng)渲染完成后,再更新高度。
代碼實(shí)現(xiàn)
我們先給出實(shí)現(xiàn):
import?{?forwardRef,?useState?}?from?'react'; import?{?flushSync?}?from?'react-dom'; //?動(dòng)態(tài)列表組件 const?VariableSizeList?=?forwardRef( ??({?containerHeight,?getItemHeight,?itemCount,?itemData,?children?},?ref)?=>?{ ????ref.current?=?{ ??????resetHeight:?()?=>?{ ????????setOffsets(genOffsets()); ??????} ????}; ????//?children?語(yǔ)義不好,賦值給?Component ????const?Component?=?children; ????const?[scrollTop,?setScrollTop]?=?useState(0);?//?滾動(dòng)位置 ????//?根據(jù)?getItemHeight?生成?offsets ????//?本質(zhì)是前綴和 ????const?genOffsets?=?()?=>?{ ??????const?a?=?[]; ??????a[0]?=?getItemHeight(0); ??????for?(let?i?=?1;?i?<?itemCount;?i++)?{ ????????a[i]?=?getItemHeight(i)?+?a[i?-?1]; ??????} ??????return?a; ????}; ????//?所有?items?的位置 ????const?[offsets,?setOffsets]?=?useState(()?=>?{ ??????return?genOffsets(); ????}); ????//?找?startIdx?和?endIdx ????//?這里用了普通的查找,更好的方式是二分查找 ????let?startIdx?=?offsets.findIndex((pos)?=>?pos?>?scrollTop); ????let?endIdx?=?offsets.findIndex((pos)?=>?pos?>?scrollTop?+?containerHeight); ????if?(endIdx?===?-1)?endIdx?=?itemCount; ????const?paddingCount?=?2; ????startIdx?=?Math.max(startIdx?-?paddingCount,?0);?//?處理越界情況 ????endIdx?=?Math.min(endIdx?+?paddingCount,?itemCount?-?1); ????//?計(jì)算內(nèi)容總高度 ????const?contentHeight?=?offsets[offsets.length?-?1]; ????//?需要渲染的?items ????const?items?=?[]; ????for?(let?i?=?startIdx;?i?<=?endIdx;?i++)?{ ??????const?top?=?i?===?0???0?:?offsets[i?-?1]; ??????const?height?=?i?===?0???offsets[0]?:?offsets[i]?-?offsets[i?-?1]; ??????items.push( ????????<Component ??????????key={i} ??????????index={i} ??????????style={{ ????????????position:?'absolute', ????????????left:?0, ????????????top, ????????????width:?'100%', ????????????height ??????????}} ??????????data={itemData} ????????/> ??????); ????} ????return?( ??????<div ????????style={{ ??????????height:?containerHeight, ??????????overflow:?'auto', ??????????position:?'relative' ????????}} ????????onScroll={(e)?=>?{ ??????????flushSync(()?=>?{ ????????????setScrollTop(e.target.scrollTop); ??????????}); ????????}} ??????> ????????<div?style={{?height:?contentHeight?}}>{items}</div> ??????</div> ????); ??} );
線上 demo:
https://codesandbox.io/s/4oy84f
效果:
思路說(shuō)明
和列表項(xiàng)等高的實(shí)現(xiàn)不同,這里不能傳一個(gè)固定值 itemHeight,改為傳入一個(gè)根據(jù) index 獲取列表項(xiàng)寬度函數(shù)?getItemHeight(index)
。
組件會(huì)通過(guò)這個(gè)函數(shù),來(lái)拿到不同列表項(xiàng)的高度,來(lái)計(jì)算出 offsets 數(shù)組。offsets 是每個(gè)列表項(xiàng)的底邊到頂部的距離。offsets 的作用是在滾動(dòng)到特定位置時(shí),計(jì)算出需要渲染的列表項(xiàng)有哪些。
當(dāng)然你也可以用高度數(shù)組,但查找起來(lái)并沒(méi)有優(yōu)勢(shì),你需要累加。offsets 是 heights 的累加緩存結(jié)果(其實(shí)也就是前綴和)。
假設(shè)幾個(gè)列表項(xiàng)的高度數(shù)組 heights 為?[10, 20, 40, 100]
,那么 offsets 就是?[10, 30, 70, 170]
。一推導(dǎo)公式為:offsets[i] = offsets[i-1] + heights[i]
下面是計(jì)算 offsets 的代碼:
const?genOffsets?=?()?=>?{ ??const?a?=?[]; ??a[0]?=?getItemHeight(0); ??for?(let?i?=?1;?i?<?itemCount;?i++)?{ ????a[i]?=?getItemHeight(i)?+?a[i?-?1]; ??} ??return?a; }; //?所有?items?的位置 const?[offsets,?setOffsets]?=?useState(()?=>?{ ??return?genOffsets(); });
getItemHeight 在列表項(xiàng)能渲染前,會(huì)提供一個(gè)預(yù)估高度 estimatedItemHeight。
//?高度數(shù)組,當(dāng)列表項(xiàng)渲染完成時(shí),更新它 const?heightsRef?=?useRef(new?Array(100)); //?預(yù)估高度 const?estimatedItemHeight?=?40; const?getHeight?=?(index)?=>?{ ??return?heightsRef.current[index]????estimatedItemHeight; };
這里我用 genOffsets 函數(shù)生成了一個(gè)完整的 offsets 數(shù)組。
其實(shí),我們也可以考慮做?惰性計(jì)算:一開始不計(jì)算出整個(gè) offsets ,而是只計(jì)算前幾個(gè) item 的 offset,并通過(guò)這幾個(gè)高度來(lái)推測(cè)一個(gè)總內(nèi)容高度。然后在后面滾動(dòng)時(shí)再一點(diǎn)點(diǎn)補(bǔ)充 offset,再一點(diǎn)點(diǎn)修正總內(nèi)容高度。
為了讓調(diào)用者可以手動(dòng)觸發(fā)高度的重新計(jì)算。虛擬列表組件通過(guò) ref?提供了一個(gè) resetHeight 方法來(lái)重置緩存的高度。
ref.current?=?{ ??resetHeight:?()?=>?{ ????setOffsets(genOffsets()); ??} }; //?使用方式 <VariableSizeList?ref={listRef}?/> listRef.current.resetHeight();
計(jì)算出 offsets 數(shù)組后,我們就可以計(jì)算需要渲染的列表項(xiàng)的起始(startIdx)和結(jié)束(endIdx)位置了。
因?yàn)?offsets 是有序數(shù)組,我們需要用?高效的二分查找?去查找,時(shí)間復(fù)雜度為?O(log n)
。
(這里我偷懶直接用了從左往右查找,沒(méi)有去做二分查找的實(shí)現(xiàn))
//?找?startIdx?和?endIdx //?這里偷懶用了普通的查找,最好的方式是二分查找 let?startIdx?=?offsets.findIndex((pos)?=>?pos?>?scrollTop); let?endIdx?=?offsets.findIndex((pos)?=>?pos?>?scrollTop?+?containerHeight); if?(endIdx?===?-1)?endIdx?=?itemCount; //?上下擴(kuò)展補(bǔ)充幾個(gè)?item const?paddingCount?=?2; startIdx?=?Math.max(startIdx?-?paddingCount,?0);?//?處理越界情況 endIdx?=?Math.min(endIdx?+?paddingCount,?itemCount?-?1);
然后內(nèi)容高度就是:
//?計(jì)算高度 const?contentHeight?=?offsets[offsets.length?-?1];
需要渲染的 items:
const?items?=?[]; for?(let?i?=?startIdx;?i?<=?endIdx;?i++)?{ ??//?計(jì)算到頂部距離 ??const?top?=?i?===?0???0?:?offsets[i?-?1]; ??//?item?的高度 ??const?height?=?i?===?0???offsets[0]?:?(offsets[i]?-?offsets[i?-?1]); ??items.push( ????<Component ??????key={i} ??????index={i} ??????style={{ ????????position:?'absolute', ????????left:?0, ????????top, ????????width:?'100%', ????????height ??????}} ??????data={itemData} ????/> ??); }
后面的 div 結(jié)構(gòu)和前面的列表項(xiàng)高度固定實(shí)現(xiàn)的基本一樣,但我這里換成了絕對(duì)定位實(shí)現(xiàn)。就不過(guò)多贅述了。
return?( ??<div ????style={{ ??????height:?containerHeight, ??????overflow:?'auto', ??????position:?'relative' ????}} ????onScroll={(e)?=>?{ ??????flushSync(()?=>?{ ????????setScrollTop(e.target.scrollTop); ??????}); ????}} ??> ????<div?style={{?height:?contentHeight?}}>{items}</div> ??</div> );
一些需要注意的問(wèn)題
- 容器寬度變化時(shí),會(huì)導(dǎo)致大量列表項(xiàng)的高度變化,需要手動(dòng)觸發(fā)重置虛擬列表緩存的高度集合,建議寬度固定;
- 圖片加載需要時(shí)間,尤其是圖片多的情況下,會(huì)讓一個(gè)列表項(xiàng)的高度不斷變大,需要你手動(dòng)觸發(fā)重置虛擬列表高度。可以考慮給圖片預(yù)設(shè)一個(gè)寬高,在加載前占據(jù)好高度;
- 因?yàn)轭A(yù)估高度并不準(zhǔn)確,會(huì)導(dǎo)致內(nèi)容高度一直變化。這就是拖動(dòng)滾動(dòng)條進(jìn)行滾動(dòng)時(shí),滑塊和光標(biāo)位置慢慢對(duì)不上的原因。
- 要考慮獲取列表項(xiàng)的高度并更新虛擬列表高度的時(shí)機(jī),可能需要配合 Obsever 監(jiān)聽變化;
- 因?yàn)椴皇卿秩舅辛斜眄?xiàng),所以像是?
.item:nth-of-type(2n)
?的 CSS 樣式會(huì)不符合預(yù)期。你需要改成用 JS 根據(jù) index 來(lái)應(yīng)用樣式,如backgroundColor: index % 2 === 0 ? 'burlywood' : 'cadetblue'
。
結(jié)尾
虛擬列表的實(shí)現(xiàn),核心在于根據(jù)滾動(dòng)位置計(jì)算落在可視區(qū)域的列表項(xiàng)范圍。
對(duì)于高度固定的情況,實(shí)現(xiàn)會(huì)比較簡(jiǎn)單,因?yàn)槲覀冇薪^對(duì)正確的數(shù)據(jù)。
對(duì)于高度動(dòng)態(tài)的情況,就復(fù)雜得多,要在列表項(xiàng)渲染后才能得到高度,為此需要設(shè)置一個(gè)預(yù)估高度,并在列表項(xiàng)渲染之后更新高度。
本文中虛擬列表組件的 API 參考了 react-window 庫(kù)。如果你需要在生產(chǎn)環(huán)境使用虛擬列表,推薦使用 react-window,它的功能會(huì)更強(qiáng)大。
原文鏈接:https://mp.weixin.qq.com/s/qF8YIjD0HeluDHbCiqbYag
相關(guān)推薦
- 2022-02-23 圖片返回base64數(shù)據(jù)渲染為圖片的處理
- 2022-10-30 詳解Python?中的命名空間、變量和范圍_python
- 2022-12-09 深入了解Rust中泛型的使用_Rust語(yǔ)言
- 2022-05-12 kotlin ..與 until 區(qū)別
- 2022-12-04 React條件渲染實(shí)例講解使用_React
- 2022-10-31 Linux系統(tǒng)docker部署.net?core3.1的詳細(xì)步驟_docker
- 2022-09-03 ASP.NET中Response.BufferOutput屬性的使用技巧_實(shí)用技巧
- 2023-10-11 MP、MybatisPlus、聯(lián)表查詢、自定義sql、Constants.WRAPPER、ew (一
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細(xì)win安裝深度學(xué)習(xí)環(huán)境2025年最新版(
- Linux 中運(yùn)行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲(chǔ)小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運(yùn)算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認(rèn)證信息的處理
- Spring Security之認(rèn)證過(guò)濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設(shè)
- maven:解決release錯(cuò)誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實(shí)現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務(wù)發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡(jiǎn)單動(dòng)態(tài)字符串(SD
- arthas操作spring被代理目標(biāo)對(duì)象命令
- Spring中的單例模式應(yīng)用詳解
- 聊聊消息隊(duì)列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠(yuǎn)程分支