react-tiny-virtual-list的原始碼解讀
前言
本文原始碼分析基於 v2.2.0 以及本文 demo 的測試環境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
從 ofollow,noindex" target="_blank">文件 來看,該庫支援橫向和縱向兩個方向的滾動(通過 scrollDirection
屬性設定,預設是垂直方向),我們選擇 垂直方向 來分析。
另外有兩個需要說明的一個屬性是 itemSize
和 estimatedItemSize
。 itemSize
用於設定列表項的高度:
(index: number): number
如果不知道 itemSize
的值,則可用 estimatedItemSize
屬性給列表項元素一個預估的高度,這樣就能預估高度計算列表內容的總高度,並且總高度隨著列表項的渲染而漸進調整;這個在列表項是動態高度的場景下很有用,可以初始化內容的總高度以撐開容器元素,使其可在垂直方向滾動。
初步瞭解這兩個屬性之後,我們先看下其採用的 DOM 結構。
內部的 DOM 結構
要了解元件的 DOM 結構,先看元件的 render 方法:
// src/index.tsx // ... const STYLE_WRAPPER: React.CSSProperties = { overflow: 'auto', willChange: 'transform', WebkitOverflowScrolling: 'touch', }; const STYLE_INNER: React.CSSProperties = { position: 'relative', width: '100%', minHeight: '100%', }; // ... render () { const { // ... height, width, style, ...props } = this.props // ... // 可視區域內被渲染的元素列表 const items: React.ReactNode[] = [] // 滾動容器元素的內聯樣式 const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width} // 可滾動區域的內聯樣式 const innerStyle = { ...STYLE_INNER, // 根據 scrollDirection 設定可滾動區域的總高度(或寬度) [sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize(), } const items: React.ReactNode[] = [] // ... return ( <div ref={this.getRef} {...props} style={wrapperStyle}> <div style={innerStyle}>{items}</div> </div> ) // ... } // ...
items
是可視區域內被渲染的元素列表。 sizeAndPositionManager
是類 SizeAndPositionManager
的一個例項,用於管理列表及列表項的大小和位置偏移:
// src/index.tsx // ... itemSizeGetter = (itemSize: Props['itemSize']) => { return index => this.getSize(index, itemSize); }; sizeAndPositionManager = new SizeAndPositionManager({ // 總的資料個數 itemCount: this.props.itemCount, // 根據索引獲取列表項的大小 itemSizeGetter: this.itemSizeGetter(this.props.itemSize), // 列表項的預估大小 estimatedItemSize: this.getEstimatedItemSize(), }); // ... // 根據 itemSize 的資料型別返回列表項的大小 private getSize(index: number, itemSize) { if (typeof itemSize === 'function') { return itemSize(index); } return Array.isArray(itemSize) ? itemSize[index] : itemSize; } // ... // 獲取列表項的預估大小 private getEstimatedItemSize(props = this.props) { return ( props.estimatedItemSize || (typeof props.itemSize === 'number' && props.itemSize) || 50 ); }
獲取到預估大小之後,就能預估可滾動區域的總大小了:
// src/SizeAndPositionManager.tsx export default class SizeAndPositionManager { // ... constructor({itemCount, itemSizeGetter, estimatedItemSize}: Options) { this.itemSizeGetter = itemSizeGetter; this.itemCount = itemCount; this.estimatedItemSize = estimatedItemSize; // 快取 item 的大小(size)和位置偏移(offset),以元素索引為 key // offset 是對應列表項的上邊框到第一個元素的上邊框的偏移距離 // 例如:this.itemSizeAndPositionData[1] = {size: 100, offset: 120} this.itemSizeAndPositionData = {}; // 最後一個被計算過的元素索引 // 索引小於該值的元素都被計算過了,反之沒有,要用預估的大小 // 預設值是 -1 this.lastMeasuredIndex = -1; } // ... // 返回最後一個被計算過元素的大小和偏移 // 如果沒有就返回一個預設的初始值 getSizeAndPositionOfLastMeasuredItem() { return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : {offset: 0, size: 0}; } // 返回可滾動區域的總大小(高度或寬度) getTotalSize(): number { const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); return ( lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize ); } // ... }
如果列表項的預估高度是 100,總資料個數是 200,那初始化時的預估高度就是 (200 - (-1) - 1) * 100 = 20000,這樣就可以撐開滾動容器元素。
計算 startIndex 和 endIndex
知道了該庫怎麼預估初始化高度的,接下來看看它是怎麼計算 startIndex
和 endIndex
的。我們繼續看它的 render
方法:
// src/index.tsx // ... render () { const { // ... renderItem, overscanCount = 3, height, width, style, ...props } = this.props // ... const {offset} = this.state; const {start, stop} = this.sizeAndPositionManager.getVisibleRange({ // 這裡根據 scrollDirection 設定滾動容器元素的總高度(或寬度) containerSize: this.props[sizeProp[scrollDirection]] || 0, offset, overscanCount, }); // 可視區域內被渲染的元素列表 const items: React.ReactNode[] = [] // 滾動容器元素的內聯樣式 const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width} // 可滾動區域的內聯樣式 const innerStyle = { ...STYLE_INNER, // 根據 scrollDirection 設定可滾動區域的總高度(或寬度) [sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize(), } // ... // ... if (typeof start !== 'undefined' && typeof stop !== 'undefined') { for (let index = start; index <= stop; index++) { // ... items.push( renderItem({ index, // 設定每個列表項元素的內聯 style style: this.getStyle(index, false), }), ); } // ... } // ... return ( <div ref={this.getRef} {...props} style={wrapperStyle}> <div style={innerStyle}>{items}</div> </div> ) // ... } // ... // 返回每個列表項的內聯樣式 getStyle (index: number, sticky: boolean) { const style = this.styleCache[index]; // 如果有快取了,直接返回 if (style) { return style; } const {scrollDirection = DIRECTION.VERTICAL} = this.props; // 根據 index,計算對應元素的大小和位置偏移 const { size, offset, } = this.sizeAndPositionManager.getSizeAndPositionForIndex(index); // 根據 sticky 返回對應元素的 postion // sticky 決定了列表項元素的 postion 是 absolute 還是 sticky return (this.styleCache[index] = sticky ? { ...STYLE_STICKY_ITEM, [sizeProp[scrollDirection]]: size, [marginProp[scrollDirection]]: offset, [oppositeMarginProp[scrollDirection]]: -(offset + size), zIndex: 1, } : { ...STYLE_ITEM, // 根據 scrollDirection 設定列表項元素的 height(或 width) [sizeProp[scrollDirection]]: size, // 根據 scrollDirection 設定列表項元素的 top(或 left) [positionProp[scrollDirection]]: offset, }); } // ...
從上述簡化後的關鍵程式碼可以看到,該庫會呼叫 getVisibleRange
方法來計算 start
和 stop
(即 startIndex
和 endIndex
),然後就可以利用這兩個邊界值來計算可視區域渲染的元素了。這裡的關鍵方法是 getVisibleRange
,它有三個引數: containerSize
、 overscanCount
以及 offset
,前兩個都是通過 props
讀取的, offset
是從 state
中讀取的:
// ... readonly state: State = { offset: this.props.scrollOffset || (this.props.scrollToIndex != null && this.getOffsetForIndex(this.props.scrollToIndex)) || 0, scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED, }; // ...
如果沒有設定 scrollOffset
和 scrollToIndex
屬性, offset
的預設值是 0。從文件來看, scrollOffset
是設定滾動容器元素預設的垂直(或水平)偏移, scrollToIndex
是設定預設滾動到哪個元素。如果設定了 scrollToIndex
,則會呼叫 getOffsetForIndex
方法獲取到該索引對應元素的偏移,因而可以認為 offset
是滾動容器元素的垂直/水平偏移,即 scrollTop/scrollLeft
的值。
接著看 getVisibleRange
的實現:
// src/SizeAndPositionManager.tsx // ... getVisibleRange({ containerSize, offset, overscanCount, }: { containerSize: number; offset: number; overscanCount: number; }): {start?: number; stop?: number} { // 獲取預估的總大小 const totalSize = this.getTotalSize(); if (totalSize === 0) { return {}; } // 計算最大偏移 const maxOffset = offset + containerSize; // 根據 offset 找到其附近的列表項的索引值 let start = this.findNearestItem(offset); if (typeof start === 'undefined') { throw Error(`Invalid offset ${offset} specified`); } // 獲取 start 對應元素的大小和偏移 const datum = this.getSizeAndPositionForIndex(start); offset = datum.offset + datum.size; // 初始化 stop let stop = start; // 如果 stop 小於總個數,則一直累加計算 start 之後的元素的偏移量 // 直到其值不小於 maxOffset,此時 stop 便對應可視區域的最後一個可見元素 while (offset < maxOffset && stop < this.itemCount - 1) { stop++; offset += this.getSizeAndPositionForIndex(stop).size; } // 如果設定了緩衝值 overscanCount // 則重新計算 start 和 stop if (overscanCount) { start = Math.max(0, start - overscanCount); stop = Math.min(stop + overscanCount, this.itemCount - 1); } // 返回 start 和 stop return { start, stop, }; } // ... // 根據索引獲取對應元素的大小和偏移 getSizeAndPositionForIndex(index: number) { if (index < 0 || index >= this.itemCount) { throw Error( `Requested index ${index} is outside of range 0..${this.itemCount}`, ); } // 如果 index 小於最後一次被計算過元素的索引,則直接從快取中讀取 if (index > this.lastMeasuredIndex) { // 獲取最後一個被計算過元素的大小和偏移 const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size; for (let i = this.lastMeasuredIndex + 1; i <= index; i++) { // 根據索引獲取對應元素的大小(高度或寬度) // 值的計算根據 itemSize prop 的型別不同而不同 const size = this.itemSizeGetter(i); if (size == null || isNaN(size)) { throw Error(`Invalid size returned for index ${i} of value ${size}`); } // 快取元素的大小和偏移 this.itemSizeAndPositionData[i] = { offset, size, }; // 累加偏移量 offset += size; } // 記錄最後一次被計算過大小的元素的索引 this.lastMeasuredIndex = index; } // 返回元素的大小和偏移 return this.itemSizeAndPositionData[index]; }
到這裡,列表怎麼在初始化渲染時怎麼獲取到可視區域內需要被渲染的元素就基本講清楚了。那麼,當用戶滾動時,是怎麼改變可視區域內需要被渲染的元素的呢?
滾動處理
我們看一下 scroll
事件的處理函式:
// src/index.tsx // ... // 滾動容器元素的 sroll 事件處理器 private handleScroll = (event: UIEvent) => { const {onScroll} = this.props; // 獲取滾動容器元素的 scrollTop/scrollLeft const offset = this.getNodeOffset(); if ( offset < 0 || this.state.offset === offset || event.target !== this.rootNode ) { return; } // 更新 state,使元件進行 re-render this.setState({ offset, scrollChangeReason: SCROLL_CHANGE_REASON.OBSERVED, }); if (typeof onScroll === 'function') { onScroll(offset, event); } }; private getNodeOffset() { const {scrollDirection = DIRECTION.VERTICAL} = this.props; // 根據 scrollDirection 返回容器元素的 scrollTop/scrollLeft return this.rootNode[scrollProp[scrollDirection]]; } // ...
當用戶滾動時,會更改 offset
值,因而元件會重新渲染,進而會重新根據新的 offset
去計算新的 start
和 stop
值。 start
和 stop
的值變了,就會改變可視區域內需要被渲染的元素。
如何處理列表項的動態高度?
要處理列表項的動態高度,關鍵在於 itemSize
屬性。 itemSize
它用於設定列表項的高度:
- 可以是一個固定值,如 100,此時列表項是固高的
- 可以是一個包含所有列表項高度的資料,如 [50, 20, 100, 80, ...]
- 可以是一個根據列表項索引返回其高度的函式:(index: number): number
在列表項是動態高度的場景下, itemSize
的值或是一個包含所有列表項高度的資料,或者一個根據索引返回類表項高度的函式。如果是陣列,則需要知道每個列表項的高度或者列表項的高度有一定的規律,這種場景是非常受限的;如果是函式,只需要返回一個高度值就行,但元素未渲染到頁面之前是無法得知其高度的,這個時候可以基於專案的實際情況,給列表項一個預估的高度: estimatedItemSize
。此外,還需要在每個列表項的大小發生改變時呼叫 recomputeSizes
(見 recomputeSizes ):
// src/index.tsx // ... recomputeSizes(startIndex = 0) { // 清空樣式快取 this.styleCache = {}; this.sizeAndPositionManager.resetItem(startIndex); } //... // src/SizeAndPositionManager.tsx // ... // 重置 lastMeasuredIndex 的值 resetItem (index) { this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1); } // ...
上文說過, lastMeasuredIndex
是最後一個被計算過大小的元素的索引。假設初始化渲染時的索引區間是 [0, 8],那在渲染完成之後, lastMeasuredIndex
的值是 8,當索引為 5 的元素的大小改變之後,那麼 索引不小於 5 的所有元素的大小和偏移都需要重新計算,因為需要將 lastMeasuredIndex
的值重置為 4。
在渲染可是區域的元素時,我們可以快取被渲染過元素的大小,當元素再次被渲染時,就可以直接通過快取讀取。
從上文可知,每個列表項都有內聯的 style,會設定元素的 height
以及定位資訊,而 height
是通過 itemSize
屬性返回的,也就是說, 該庫對動態高度的支援也是需要使用者“顯示”地返回每個列表項的高度,因而在列表項被渲染時,該列表項的高度就已經通過內聯的樣式固定了。
而當元素實際渲染的內容偏少時,那其內容高度可能會小於給定的高度,就會造成大量的留白空間:
當元素實際渲染的內容偏多時,那其內容高度可能會大於給定的高度,就會造成內容的重疊:
demo的完整程式碼戳此: TinyVirtualList
列表項在渲染圖片混合的場景下,內容重疊會更容易出現。因為圖片存在網路請求,元件內部並沒有相關的自我調整機制,而列表項在渲染時就給定了高度,這種場景下,內容重疊就很容易出現了。
總結
本文主要分析了虛擬元件庫 react-tiny-virtual-list
的實現,經過上述分析,我們可以知道,該庫實現虛擬列表的主要原理是根據 state
的 offset
值(即滾動容器元素的 scrollTop/scrollLeft
值)先計算出可視區域內第一個元素的 start
值,然後根據 start
對應元素的 offset
以及容器元素的大小,計算出當前可視區域內最後一個可見元素的索引,即 stop
值。有了 start
和 stop
值,就可以改變可視區域需要渲染的內容了。
在處理動態高度時,我分析了其不足之處,並通過一個 Demo 簡單分析了在專案中如何使用它。此外,如果你需要使用這個元件,下面兩個問題可能也是需要你考慮的:
One More Thing
原本下一篇文章想分享 [email protected] 元件的虛擬列表實現原理,但發現其與 react-tiny-virtual-list 元件無論是在 DOM 的佈局上還是 start
以及 stop
的計算規則上,實現思路基本是一樣的,所以就不展開細講了。這裡列舉部分我關注到的不同點:
- react-window 不僅支援虛擬列表,還支援虛擬網格(Virtual Grid),見 demo
- react-window 可以自定滾動容器元素以及內容容器元素的標籤,二者的預設值都是
div
- 對於
onItemsRendered
和onScroll
,react-window 在實現上通過 memoize-one 實現了計算快取,而 react-tiny-virtual-list 則是直接呼叫 - react-window 元件的
itemSize
僅支援數值或函式,不支援陣列 - 在列表項的渲染上,react-tiny-virtual-list 是通過
renderItem
回撥,而 react-window 是通過React.createElement
,相對而言,後者相對受限
基於 react-window 寫了一個渲染圖文的demo,程式碼戳此: react-window
<本文完>