近期在某平臺開發迭代的過程中遇到了超長List嵌套在antd Modal里加載慢,卡頓的情況。于是心血來潮決定從零自己實現一個虛擬滾動列表來優化一下整體的體驗。
改造前:
我們可以看出來在改造之前,打開編輯窗口Modal的時候會出現短暫的卡頓,并且在點擊Cancel關閉后也并不是立即響應而是稍作遲疑之后才關閉的
改造后:
改造完成后我們可以觀察到整個Modal的打開比之前變得流暢了不少,可以做到立即響應用戶的點擊事件喚起/關閉Modal
性能對比Demo: codesandbox.io/s/a-v-list-…
0x0 基礎知識
所以什么是虛擬滾動/列表呢?
一個虛擬列表是指當我們有成千上萬條數據需要進行展示但是用戶的“視窗”(一次性可見內容)又不大時我們可以通過巧妙的方法只渲染用戶最大可見條數+“BufferSize”個元素并在用戶進行滾動時動態更新每個元素中的內容從而達到一個和長list滾動一樣的效果但花費非常少的資源。
(從上圖中我們可以發現實際用戶每次能看到的元素/內容只有item-4 ~ item-13 也就是9個元素)
0x1 實現一個“定高”虛擬列表
首先我們需要定義幾個變量/名稱。
- 從上圖中我們可以看出來用戶實際可見區域的開始元素是Item-4,所以他在數據數組中對應的下標也就是我們的startIndex
- 同理Item-13對應的數組下標則應該是我們的endIndex
- 所以Item-1,Item-2和Item-3則是被用戶的向上滑動操作所隱藏,所以我們稱它為startOffset(scrollTop)
因為我們只對可視區域的內容做了渲染,所以為了保持整個容器的行為和一個長列表相似(滾動)我們必須保持原列表的高度,所以我們將HTML結構設計成如下
1
2
3
4
5
6
7
8
9
10
|
<!--ver 1.0 --> < div className = "vListContainer" > < div className = "phantomContent" > ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </ div > </ div > |
其中:
- vListContainer 為可視區域的容器,具有 overflow-y: auto 屬性。
- 在 phantom 中的每條數據都應該具有 position: absolute 屬性
- phantomContent 則是我們的“幻影”部分,其主要目的是為了還原真實List的內容高度從而模擬正常長列表滾動的行為。
接著我們對 vListContainer 綁定一個onScroll的響應函數,并在函數中根據原生滾動事件的scrollTop 屬性來計算我們的 startIndex 和 endIndex
- 在開始計算之前,我們先要定義幾個數值:
我們需要一個固定的列表元素高度:rowHeight
我們需要知道當前list一共有多少條數據: total
我們需要知道當前用戶可視區域的高度: height
- 在有了上述數據之后我們可以通過計算得出下列數據:
列表總高度: phantomHeight = total * rowHeight
可視范圍內展示元素數:limit = Math.ceil(height/rowHeight)
所以我們可以在onScroll 回調中進行下列計算:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
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 不同(我們需要更新數據了) if (currentStartIndex !== startIndex ) { this .startIndex = currentStartIndex; this .endIndex = Math.min(currentStartIndedx + limit, total - 1); this .setState({ scrollTop }); } } } |
當我們一旦有了startIndex 和 endIndex 我們就可以渲染其對應的數據:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
renderDisplayContent = () => { const { rowHeight, startIndex, endIndex } = this ; const content = []; // 注意這塊我們用了 <= 是為了渲染x+1個元素用來在讓滾動變得連續(永遠渲染在判斷&渲染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…
原理:
所以這個滾動效果究竟是怎么實現的呢?首先我們在vListContainer中渲染了一個真實list高度的“幻影”容器從而允許用戶進行滾動操作。其次我們監聽了onScroll事件,并且在每次用戶觸發滾動是動態計算當前滾動Offset(被滾上去隱藏了多少)所對應的開始下標(index)是多少。當我們發現新的下邊和我們當前展示的下標不同時進行賦值并且setState觸發重繪。當用戶當前的滾動offset未觸發下標更新時,則因為本身phantom的長度關系讓虛擬列表擁有和普通列表一樣的滾動能力。當觸發重繪時因為我們計算的是startIndex 所以用戶感知不到頁面的重繪(因為當前滾動的下一幀和我們重繪完的內容是一致的)。
優化:
對于上邊我們實現的虛擬列表,大家不難發現一但進行了快速滑動就會出現列表閃爍的現象/來不及渲染、空白的現象。還記得我們一開始說的 **渲染用戶最大可見條數+“BufferSize” 么?對于我們渲染的實際內容,我們可以對其上下加入Buffer的概念(即上下多渲染一些元素用來過渡快速滑動時來不及渲染的問題)。優化后的onScroll 函數如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
onScroll(evt: any) { ........ // 計算當前startIndex const currentStartIndex = Math.floor(scrollTop / rowHeight); // 如果currentStartIndex 和 startIndex 不同(我們需要更新數據了) if (currentStartIndex !== originStartIdx) { // 注意,此處我們引入了一個新的變量叫originStartIdx,起到了和之前startIndex // 相同的效果,記錄當前的 真實 開始下標。 this .originStartIdx = currentStartIndex; // 對 startIndex 進行 頭部 緩沖區 計算 this .startIndex = Math.max( this .originStartIdx - bufferSize, 0); // 對 endIndex 進行 尾部 緩沖區 計算 this .endIndex = Math.min( this .originStartIdx + this .limit + bufferSize, total - 1 ); this .setState({ scrollTop: scrollTop }); } } |
線上Demo:codesandbox.io/s/A-better-…
0x2 列表元素高度自適應
現在我們已經實現了“定高”元素的虛擬列表的實現,那么如果說碰到了高度不固定的超長列表的業務場景呢?
- 一般碰到不定高列表元素時有三種虛擬列表實現方式:
1.對輸入數據進行更改,傳入每一個元素對應的高度 dynamicHeight[i] = x x 為元素i 的行高
需要實現知道每一個元素的高度(不切實際)
2.將當前元素先在屏外進行繪制并對齊高度進行測量后再將其渲染到用戶可視區域內
這種方法相當于雙倍渲染消耗(不切實際)
3.傳入一個estimateHeight 屬性先對行高進行估計并渲染,然后渲染完成后獲得真實行高并進行更新和緩存
會引入多余的transform(可以接受),會在后邊講為什么需要多余的transform...
- 讓我們暫時先回到 HTML 部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
<!--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 > |
- 在我們實現 “定高” 虛擬列表時,我們是采用了把元素渲染在phantomContent 容器里,并且通過設置每一個item的position 為 absolute 加上定義top 屬性等于 i * rowHeight 來實現無論怎么滾動,渲染內容始終是在用戶的可視范圍內的。在列表高度不能確定的情況下,我們就無法準確的通過estimateHeight 來計算出當前元素所處的y位置,所以我們需要一個容器來幫我們做這個絕對定位。
- actualContent 則是我們新引入的列表內容渲染容器,通過在此容器上設置position: absolute 屬性來避免在每個item上設置。
- 有一點不同的是,因為我們改用actualContent 容器。當我們進行滑動時需要動態的對容器的位置進行一個 y-transform 從而實現容器永遠處于用戶的視窗之中:
1
2
3
4
5
6
7
8
9
10
11
12
|
getTransform() { const { scrollTop } = this .state; const { rowHeight, bufferSize, originStartIdx } = this ; // 當前滑動offset - 當前被截斷的(沒有完全消失的元素)距離 - 頭部緩沖區距離 return `translate3d(0,${ scrollTop - (scrollTop % rowHeight) - Math.min(originStartIdx, bufferSize) * rowHeight }px,0)`; } |
線上Demo:codesandbox.io/s/a-v-list-…
(注:當沒有高度自適應要求時且沒有實現cell復用時,把元素通過absolute渲染在phantom里會比通過transform的性能要好一些。因為每次渲染content時都會進行重排,但是如果使用transform時就相當于進行了( 重排 + transform) > 重排)
- 回到列表元素高度自適應這個問題上來,現在我們有了一個可以在內部進行正常block排布的元素渲染容器(actualContent ),我們現在就可以直接在不給定高度的情況下先把內容都渲染進去。對于之前我們需要用rowHeight 做高度計算的地方,我們統一替換成estimateHeight 進行計算。
limit = Math.ceil(height / estimateHeight)
phantomHeight = total * estimateHeight
- 同時為了避免重復計算每一個元素渲染后的高度(getBoundingClientReact().height) 我們需要一個數組來存儲這些高度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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值
1
|
this .phantomHeight = this .cachedPositions[cachedPositionsLen - 1].bottom; |
- 當我們根據estimateHeight 渲染完用戶視窗內的元素后,我們需要對渲染出來的元素做實際高度更新,此時我們可以利用componentDidUpdate 生命周期鉤子來計算、判斷和更新:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
componentDidUpdate() { ...... // actualContentRef必須存在current (已經渲染出來) + 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`; }; |
- 當我們現在有了所有元素的準確高度和位置值時,我們獲取當前scrollTop (Offset)所對應的開始元素的方法修改為通過 cachedPositions 獲?。?/li>
因為我們的cachedPositions 是一個有序數組,所以我們在搜索時可以利用二分查找來降低時間復雜度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
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); .... } }; |
- 二分查找實現:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
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的方法改造成如下:
1
2
|
getTransform = () => `translate3d(0,${ this .startIndex >= 1 ? this .cachedPositions[ this .startIndex - 1].bottom : 0}px,0)`; |
線上Demo:codesandbox.io/s/a-v-list-…
以上就是React實現一個高度自適應的虛擬列表的詳細內容,更多關于React 自適應虛擬列表的資料請關注服務器之家其它相關文章!
原文鏈接:https://juejin.cn/post/6948011958075392036