React實現(xiàn)一個高度自適應的虛擬列表
近期在某平臺開發(fā)迭代的過程中遇到了超長List嵌套在antd Modal里加載慢,卡頓的情況。于是心血來潮決定從零自己實現(xiàn)一個虛擬滾動列表來優(yōu)化一下整體的體驗。
改造前:
我們可以看出來在改造之前,打開編輯窗口Modal的時候會出現(xiàn)短暫的卡頓,并且在點擊Cancel關閉后也并不是立即響應而是稍作遲疑之后才關閉的
改造后:
改造完成后我們可以觀察到整個Modal的打開比之前變得流暢了不少,可以做到立即響應用戶的點擊事件喚起/關閉Modal
性能對比Demo: codesandbox.io/s/a-v-list-…
0x0 基礎知識
所以什么是虛擬滾動/列表呢?
一個虛擬列表是指當我們有成千上萬條數(shù)據(jù)需要進行展示但是用戶的“視窗”(一次性可見內(nèi)容)又不大時我們可以通過巧妙的方法只渲染用戶最大可見條數(shù)+“BufferSize”個元素并在用戶進行滾動時動態(tài)更新每個元素中的內(nèi)容從而達到一個和長list滾動一樣的效果但花費非常少的資源。
(從上圖中我們可以發(fā)現(xiàn)實際用戶每次能看到的元素/內(nèi)容只有item-4 ~ item-13 也就是9個元素)
0x1 實現(xiàn)一個“定高”虛擬列表
首先我們需要定義幾個變量/名稱。
- 從上圖中我們可以看出來用戶實際可見區(qū)域的開始元素是Item-4,所以他在數(shù)據(jù)數(shù)組中對應的下標也就是我們的startIndex
- 同理Item-13對應的數(shù)組下標則應該是我們的endIndex
- 所以Item-1,Item-2和Item-3則是被用戶的向上滑動操作所隱藏,所以我們稱它為startOffset(scrollTop)
因為我們只對可視區(qū)域的內(nèi)容做了渲染,所以為了保持整個容器的行為和一個長列表相似(滾動)我們必須保持原列表的高度,所以我們將HTML結(jié)構(gòu)設計成如下
<!--ver 1.0 --> <div className="vListContainer"> <div className="phantomContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>
其中:
- vListContainer 為可視區(qū)域的容器,具有 overflow-y: auto 屬性。
- 在 phantom 中的每條數(shù)據(jù)都應該具有 position: absolute 屬性
- phantomContent 則是我們的“幻影”部分,其主要目的是為了還原真實List的內(nèi)容高度從而模擬正常長列表滾動的行為。
接著我們對 vListContainer 綁定一個onScroll的響應函數(shù),并在函數(shù)中根據(jù)原生滾動事件的scrollTop 屬性來計算我們的 startIndex 和 endIndex
- 在開始計算之前,我們先要定義幾個數(shù)值:
我們需要一個固定的列表元素高度:rowHeight
我們需要知道當前l(fā)ist一共有多少條數(shù)據(jù): total
我們需要知道當前用戶可視區(qū)域的高度: height
- 在有了上述數(shù)據(jù)之后我們可以通過計算得出下列數(shù)據(jù):
列表總高度: phantomHeight = total * rowHeight
可視范圍內(nèi)展示元素數(shù):limit = Math.ceil(height/rowHeight)
所以我們可以在onScroll 回調(diào)中進行下列計算:
onScroll(evt: any) { // 判斷是否是我們需要響應的滾動事件 if (evt.target === this.scrollingContainer.current) { const { scrollTop } = evt.target; const { startIndex, total, rowHeight, limit } = this; // 計算當前startIndex const currentStartIndex = Math.floor(scrollTop / rowHeight); // 如果currentStartIndex 和 startIndex 不同(我們需要更新數(shù)據(jù)了) if (currentStartIndex !== startIndex ) { this.startIndex = currentStartIndex; this.endIndex = Math.min(currentStartIndedx + limit, total - 1); this.setState({ scrollTop }); } } }
當我們一旦有了startIndex 和 endIndex 我們就可以渲染其對應的數(shù)據(jù):
renderDisplayContent = () => { const { rowHeight, startIndex, endIndex } = this; const content = []; // 注意這塊我們用了 <= 是為了渲染x+1個元素用來在讓滾動變得連續(xù)(永遠渲染在判斷&渲染x+2) for (let i = startIndex; i <= endIndex; ++i) { // rowRenderer 是用戶定義的列表元素渲染方法,需要接收一個 index i 和 // 當前位置對應的style content.push( rowRenderer({ index: i, style: { width: '100%', height: rowHeight + 'px', position: "absolute", left: 0, right: 0, top: i * rowHeight, borderBottom: "1px solid #000", } }) ); } return content; };
線上Demo:codesandbox.io/s/a-naive-v…
原理:
所以這個滾動效果究竟是怎么實現(xiàn)的呢?首先我們在vListContainer中渲染了一個真實list高度的“幻影”容器從而允許用戶進行滾動操作。其次我們監(jiān)聽了onScroll事件,并且在每次用戶觸發(fā)滾動是動態(tài)計算當前滾動Offset(被滾上去隱藏了多少)所對應的開始下標(index)是多少。當我們發(fā)現(xiàn)新的下邊和我們當前展示的下標不同時進行賦值并且setState觸發(fā)重繪。當用戶當前的滾動offset未觸發(fā)下標更新時,則因為本身phantom的長度關系讓虛擬列表擁有和普通列表一樣的滾動能力。當觸發(fā)重繪時因為我們計算的是startIndex 所以用戶感知不到頁面的重繪(因為當前滾動的下一幀和我們重繪完的內(nèi)容是一致的)。
優(yōu)化:
對于上邊我們實現(xiàn)的虛擬列表,大家不難發(fā)現(xiàn)一但進行了快速滑動就會出現(xiàn)列表閃爍的現(xiàn)象/來不及渲染、空白的現(xiàn)象。還記得我們一開始說的 **渲染用戶最大可見條數(shù)+“BufferSize” 么?對于我們渲染的實際內(nèi)容,我們可以對其上下加入Buffer的概念(即上下多渲染一些元素用來過渡快速滑動時來不及渲染的問題)。優(yōu)化后的onScroll 函數(shù)如下:
onScroll(evt: any) { ........ // 計算當前startIndex const currentStartIndex = Math.floor(scrollTop / rowHeight); // 如果currentStartIndex 和 startIndex 不同(我們需要更新數(shù)據(jù)了) if (currentStartIndex !== originStartIdx) { // 注意,此處我們引入了一個新的變量叫originStartIdx,起到了和之前startIndex // 相同的效果,記錄當前的 真實 開始下標。 this.originStartIdx = currentStartIndex; // 對 startIndex 進行 頭部 緩沖區(qū) 計算 this.startIndex = Math.max(this.originStartIdx - bufferSize, 0); // 對 endIndex 進行 尾部 緩沖區(qū) 計算 this.endIndex = Math.min( this.originStartIdx + this.limit + bufferSize, total - 1 ); this.setState({ scrollTop: scrollTop }); } }
線上Demo:codesandbox.io/s/A-better-…
0x2 列表元素高度自適應
現(xiàn)在我們已經(jīng)實現(xiàn)了“定高”元素的虛擬列表的實現(xiàn),那么如果說碰到了高度不固定的超長列表的業(yè)務場景呢?
- 一般碰到不定高列表元素時有三種虛擬列表實現(xiàn)方式:
1.對輸入數(shù)據(jù)進行更改,傳入每一個元素對應的高度 dynamicHeight[i] = x x 為元素i 的行高
需要實現(xiàn)知道每一個元素的高度(不切實際)
2.將當前元素先在屏外進行繪制并對齊高度進行測量后再將其渲染到用戶可視區(qū)域內(nèi)
這種方法相當于雙倍渲染消耗(不切實際)
3.傳入一個estimateHeight 屬性先對行高進行估計并渲染,然后渲染完成后獲得真實行高并進行更新和緩存
會引入多余的transform(可以接受),會在后邊講為什么需要多余的transform...
- 讓我們暫時先回到 HTML 部分
<!--ver 1.0 --> <div className="vListContainer"> <div className="phantomContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div> <!--ver 1.1 --> <div className="vListContainer"> <div className="phantomContent" /> <div className="actualContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>
- 在我們實現(xiàn) “定高” 虛擬列表時,我們是采用了把元素渲染在phantomContent 容器里,并且通過設置每一個item的position 為 absolute 加上定義top 屬性等于 i * rowHeight 來實現(xiàn)無論怎么滾動,渲染內(nèi)容始終是在用戶的可視范圍內(nèi)的。在列表高度不能確定的情況下,我們就無法準確的通過estimateHeight 來計算出當前元素所處的y位置,所以我們需要一個容器來幫我們做這個絕對定位。
- actualContent 則是我們新引入的列表內(nèi)容渲染容器,通過在此容器上設置position: absolute 屬性來避免在每個item上設置。
- 有一點不同的是,因為我們改用actualContent 容器。當我們進行滑動時需要動態(tài)的對容器的位置進行一個 y-transform 從而實現(xiàn)容器永遠處于用戶的視窗之中:
getTransform() { const { scrollTop } = this.state; const { rowHeight, bufferSize, originStartIdx } = this; // 當前滑動offset - 當前被截斷的(沒有完全消失的元素)距離 - 頭部緩沖區(qū)距離 return `translate3d(0,${ scrollTop - (scrollTop % rowHeight) - Math.min(originStartIdx, bufferSize) * rowHeight }px,0)`; }
線上Demo:codesandbox.io/s/a-v-list-…
(注:當沒有高度自適應要求時且沒有實現(xiàn)cell復用時,把元素通過absolute渲染在phantom里會比通過transform的性能要好一些。因為每次渲染content時都會進行重排,但是如果使用transform時就相當于進行了( 重排 + transform) > 重排)
- 回到列表元素高度自適應這個問題上來,現(xiàn)在我們有了一個可以在內(nèi)部進行正常block排布的元素渲染容器(actualContent ),我們現(xiàn)在就可以直接在不給定高度的情況下先把內(nèi)容都渲染進去。對于之前我們需要用rowHeight 做高度計算的地方,我們統(tǒng)一替換成estimateHeight 進行計算。
limit = Math.ceil(height / estimateHeight)
phantomHeight = total * estimateHeight
- 同時為了避免重復計算每一個元素渲染后的高度(getBoundingClientReact().height) 我們需要一個數(shù)組來存儲這些高度
interface CachedPosition { index: number; // 當前pos對應的元素的下標 top: number; // 頂部位置 bottom: number; // 底部位置 height: number; // 元素高度 dValue: number; // 高度是否和之前(estimate)存在不同 } cachedPositions: CachedPosition[] = []; // 初始化cachedPositions initCachedPositions = () => { const { estimatedRowHeight } = this; this.cachedPositions = []; for (let i = 0; i < this.total; ++i) { this.cachedPositions[i] = { index: i, height: estimatedRowHeight, // 先使用estimateHeight估計 top: i * estimatedRowHeight, // 同上 bottom: (i + 1) * estimatedRowHeight, // same above dValue: 0, }; } };
- 當我們計算完(初始化完) cachedPositions 之后由于我們計算了每一個元素的top和bottom,所以phantom 的高度就是cachedPositions 中最后一個元素的bottom值
this.phantomHeight = this.cachedPositions[cachedPositionsLen - 1].bottom;
- 當我們根據(jù)estimateHeight 渲染完用戶視窗內(nèi)的元素后,我們需要對渲染出來的元素做實際高度更新,此時我們可以利用componentDidUpdate 生命周期鉤子來計算、判斷和更新:
componentDidUpdate() { ...... // actualContentRef必須存在current (已經(jīng)渲染出來) + total 必須 > 0 if (this.actualContentRef.current && this.total > 0) { this.updateCachedPositions(); } } updateCachedPositions = () => { // update cached item height const nodes: NodeListOf<any> = this.actualContentRef.current.childNodes; const start = nodes[0]; // calculate height diff for each visible node... nodes.forEach((node: HTMLDivElement) => { if (!node) { // scroll too fast?... return; } const rect = node.getBoundingClientRect(); const { height } = rect; const index = Number(node.id.split('-')[1]); const oldHeight = this.cachedPositions[index].height; const dValue = oldHeight - height; if (dValue) { this.cachedPositions[index].bottom -= dValue; this.cachedPositions[index].height = height; this.cachedPositions[index].dValue = dValue; } }); // perform one time height update... let startIdx = 0; if (start) { startIdx = Number(start.id.split('-')[1]); } const cachedPositionsLen = this.cachedPositions.length; let cumulativeDiffHeight = this.cachedPositions[startIdx].dValue; this.cachedPositions[startIdx].dValue = 0; for (let i = startIdx + 1; i < cachedPositionsLen; ++i) { const item = this.cachedPositions[i]; // update height this.cachedPositions[i].top = this.cachedPositions[i - 1].bottom; this.cachedPositions[i].bottom = this.cachedPositions[i].bottom - cumulativeDiffHeight; if (item.dValue !== 0) { cumulativeDiffHeight += item.dValue; item.dValue = 0; } } // update our phantom div height const height = this.cachedPositions[cachedPositionsLen - 1].bottom; this.phantomHeight = height; this.phantomContentRef.current.style.height = `${height}px`; };
- 當我們現(xiàn)在有了所有元素的準確高度和位置值時,我們獲取當前scrollTop (Offset)所對應的開始元素的方法修改為通過 cachedPositions 獲取:
因為我們的cachedPositions 是一個有序數(shù)組,所以我們在搜索時可以利用二分查找來降低時間復雜度
getStartIndex = (scrollTop = 0) => { let idx = binarySearch<CachedPosition, number>(this.cachedPositions, scrollTop, (currentValue: CachedPosition, targetValue: number) => { const currentCompareValue = currentValue.bottom; if (currentCompareValue === targetValue) { return CompareResult.eq; } if (currentCompareValue < targetValue) { return CompareResult.lt; } return CompareResult.gt; } ); const targetItem = this.cachedPositions[idx]; // Incase of binarySearch give us a not visible data(an idx of current visible - 1)... if (targetItem.bottom < scrollTop) { idx += 1; } return idx; }; onScroll = (evt: any) => { if (evt.target === this.scrollingContainer.current) { .... const currentStartIndex = this.getStartIndex(scrollTop); .... } };
- 二分查找實現(xiàn):
export enum CompareResult { eq = 1, lt, gt, } export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) { let start = 0; let end = list.length - 1; let tempIndex = null; while (start <= end) { tempIndex = Math.floor((start + end) / 2); const midValue = list[tempIndex]; const compareRes: CompareResult = compareFunc(midValue, value); if (compareRes === CompareResult.eq) { return tempIndex; } if (compareRes === CompareResult.lt) { start = tempIndex + 1; } else if (compareRes === CompareResult.gt) { end = tempIndex - 1; } } return tempIndex; }
- 最后,我們滾動后獲取transform的方法改造成如下:
getTransform = () => `translate3d(0,${this.startIndex >= 1 ? this.cachedPositions[this.startIndex - 1].bottom : 0}px,0)`;
線上Demo:codesandbox.io/s/a-v-list-…
以上就是React實現(xiàn)一個高度自適應的虛擬列表的詳細內(nèi)容,更多關于React 自適應虛擬列表的資料請關注腳本之家其它相關文章!
相關文章
React Native中實現(xiàn)動態(tài)導入的示例代碼
隨著業(yè)務的發(fā)展,每一個 React Native 應用的代碼數(shù)量都在不斷增加。作為一個前端想到的方案自然就是動態(tài)導入(Dynamic import)了,本文介紹了React Native中實現(xiàn)動態(tài)導入的示例代碼,需要的可以參考一下2022-06-06React學習之受控組件與數(shù)據(jù)共享實例分析
這篇文章主要介紹了React學習之受控組件與數(shù)據(jù)共享,結(jié)合實例形式分析了React受控組件與組件間數(shù)據(jù)共享相關原理與使用技巧,需要的朋友可以參考下2020-01-01