react-virtualized 元件的虛擬列表優化分析
前言
本文原始碼分析基於 v9.20.1 以及本文 demo 的測試環境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
在 ofollow,noindex" target="_blank">上一篇 文章中,我簡單分析了 react-virtualized
的 List 元件是怎麼實現虛擬列表的,在文章的最後,留下了一個問題:怎麼儘量避免元素內容重疊的問題?本篇將進行簡單分析。
react-virtualized
的 List 元件雖然存在上述所說的問題,但是它還是可以通過和其它元件的組合來做的更好, 儘量避免在渲染圖文場景下的元素內容重疊問題。
在 Rendering large lists with React Virtualized 一文中介紹了怎麼通過 react-virtualized 來做長列表資料的渲染優化,並詳細介紹通過 AutoSizer
和 CellMeasurer
元件來實現 List 元件對列表項動態高度的支援:
- AutoSizer:可以自動調整其子元件大小(高度和寬度)的高階元件
- CellMeasurer:會自動計算元件的大小(高度和寬度)
這篇文章我們就分析一下這兩個元件。
AutoSizer
如果不使用 AutoSizer
元件,直接使用 List
元件可能如下:
<List width={rowWidth} height={750} rowHeight={rowHeight} rowRenderer={this.renderRow} rowCount={this.list.length} overscanRowCount={3} />
使用 AutoSizer
元件之後,程式碼可能變成如下:
<AutoSizer disableHeight> { ({width, height}) => ( <List width={width} height={750} rowHeight={rowHeight} rowRenderer={this.renderRow} rowCount={this.list.length} overscanRowCount={3} /> ) } </AutoSizer>
因為 List
元件使用了一個固定高度,所以將 AutoSizer
的 disableHeight
設定成 true
就相當於告訴 AutoSizer
元件不需要管理子元件的高度。
AutoSizer
的實現也比較簡單,先看起 render
方法:
// source/AutoSizer/AutoSizer.js // ... render() { const { children, className, disableHeight, disableWidth, style, } = this.props; const {height, width} = this.state; // 外部 div 的樣式,外部 div 不需要設定高寬 // 而內部元件應該使用被計算後的高寬值 // https://github.com/bvaughn/react-virtualized/issues/68 const outerStyle: Object = {overflow: 'visible'}; const childParams: Object = {}; if (!disableHeight) { outerStyle.height = 0; childParams.height = height; } if (!disableWidth) { outerStyle.width = 0; childParams.width = width; } return ( <div className={className} ref={this._setRef} style={{ ...outerStyle, ...style, }}> {children(childParams)} </div> ); } // ... _setRef = (autoSizer: ?HTMLElement) => { this._autoSizer = autoSizer; }; // ...
然後再看下 componentDidMount
方法:
// source/AutoSizer/AutoSizer.js // ... componentDidMount() { const {nonce} = this.props; // 這裡的每一個條件都可能是為了修復某一個邊界問題(edge-cases),如 #203 #960 #150 etc. if ( this._autoSizer && this._autoSizer.parentNode && this._autoSizer.parentNode.ownerDocument && this._autoSizer.parentNode.ownerDocument.defaultView && this._autoSizer.parentNode instanceof this._autoSizer.parentNode.ownerDocument.defaultView.HTMLElement ) { // 獲取父節點 this._parentNode = this._autoSizer.parentNode; // 建立監聽器,用於監聽元素大小的變化 this._detectElementResize = createDetectElementResize(nonce); // 設定需要被監聽的節點以及回撥處理 this._detectElementResize.addResizeListener( this._parentNode, this._onResize, ); this._onResize(); } } // ...
在 componentDidMount
方法中,主要建立了監聽元素大小變化的監聽器。 createDetectElementResize
方法( 原始碼 )是基於 javascript-detect-element-resize 實現的,針對 SSR 的支援更改了一些程式碼。接下來看下 _onResize
的實現:
// source/AutoSizer/AutoSizer.js // ... _onResize = () => { const {disableHeight, disableWidth, onResize} = this.props; if (this._parentNode) { // 獲取節點的高寬 const height = this._parentNode.offsetHeight || 0; const width = this._parentNode.offsetWidth || 0; const style = window.getComputedStyle(this._parentNode) || {}; const paddingLeft = parseInt(style.paddingLeft, 10) || 0; const paddingRight = parseInt(style.paddingRight, 10) || 0; const paddingTop = parseInt(style.paddingTop, 10) || 0; const paddingBottom = parseInt(style.paddingBottom, 10) || 0; // 計算新的高寬 const newHeight = height - paddingTop - paddingBottom; const newWidth = width - paddingLeft - paddingRight; if ( (!disableHeight && this.state.height !== newHeight) || (!disableWidth && this.state.width !== newWidth) ) { this.setState({ height: height - paddingTop - paddingBottom, width: width - paddingLeft - paddingRight, }); onResize({height, width}); } } }; // ...
_onResize
方法做的事就是計算元素新的高寬,並更新 state
,觸發 re-render
。接下來看看 CellMeasurer
元件的實現。
CellMeasurer
CellMeasurer
元件會根據自身的內容自動計算大小,需要配合 CellMeasurerCache
元件使用,這個元件主要快取已計算過的 cell 元素的大小。
先修改一下程式碼,看看其使用方式:
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized"; class App extends Component { constructor() { ... this.cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: 180 }); } ... }
首先,我們建立了 CellMeasurerCache
例項,並設定了兩個屬性:
- fixedWidth:表示 cell 元素是固定寬度的,但高度是動態的
- defaultHeight:未被渲染的 cell 元素的預設高度(或預估高度)
然後,我們需要修改 List
元件的 renderRow
方法以及 List
元件:
// ... renderRow({ index, key, style, parent }) { // 上一篇分析過,List 是 columnCount 為 1 的 Grid 元件, // 因而 columnIndex 是固定的 0 return ( <CellMeasurer key={key} cache={this.cache} parent={parent} columnIndex={0} rowIndex={index}> <div style={style} className="row"> { // 省略 } </div> </CellMeasurer> ); } // ... <AutoSizer disableHeight> { ({width, height}) => ( <List width={width} height={750} rowHeight={this.cache.rowHeight} deferredMeasurementCache={this.cache} rowRenderer={this.renderRow} rowCount={this.list.length} overscanRowCount={3} /> ) } </AutoSizer>
對於 List
元件有三個變動:
-
rowHeight
屬性的值變成this.cache.rowHeight
- 新增了
deferredMeasurementCache
屬性,並且其值為CellMeasurerCache
的例項 - 在
renderRow
方法返回的元素外用CellMeasurer
元件包裹了一層
從 List
元件的 文件 看,並沒有 deferredMeasurementCache
屬性說明,但在上一篇文章分析過, List
元件的內部實現是基於 Grid
元件的:
// source/List/List.js // ... render() { //... return ( <Grid {...this.props} autoContainerWidth cellRenderer={this._cellRenderer} className={classNames} columnWidth={width} columnCount={1} noContentRenderer={noRowsRenderer} onScroll={this._onScroll} onSectionRendered={this._onSectionRendered} ref={this._setRef} scrollToRow={scrollToIndex} /> ); } // ...
而 Grid
元件是擁有這個屬性的,其值是 CellMeasurer
例項,因而這個屬性實際上是傳遞給了 Grid
元件。
回到 CellMeasurer
元件,其實現是比較簡單的:
// source/CellMeasurer/CellMeasurer.js // ... componentDidMount() { this._maybeMeasureCell(); } componentDidUpdate() { this._maybeMeasureCell(); } render() { const {children} = this.props; return typeof children === 'function' ? children({measure: this._measure}) : children; } // ...
上述程式碼非常簡單, render
方法只做子元件的渲染,並在元件掛載和更新的時候都去呼叫 _maybeMeasureCell
方法,這個方法就會去計算 cell 元素的大小了:
// source/CellMeasurer/CellMeasurer.js // ... // 獲取元素的大小 _getCellMeasurements() { // 獲取 CellMeasurerCache 例項 const {cache} = this.props; // 獲取元件自身對應的 DOM 節點 const node = findDOMNode(this); if ( node && node.ownerDocument && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement ) { // 獲取節點對應的大小 const styleWidth = node.style.width; const styleHeight = node.style.height; /** * 建立 CellMeasurerCache 例項時,如果設定了 fixedWidth 為 true, * 則 hasFixedWidth() 返回 true;如果設定了 fixedHeight 為 true, * 則 hasFixedHeight() 返回 true。兩者的預設值都是 false * 將 width 或 heigth 設定成 auto,便於得到元素的實際大小 **/ if (!cache.hasFixedWidth()) { node.style.width = 'auto'; } if (!cache.hasFixedHeight()) { node.style.height = 'auto'; } const height = Math.ceil(node.offsetHeight); const width = Math.ceil(node.offsetWidth); // 獲取到節點的實際大小之後,需要重置樣式 // https://github.com/bvaughn/react-virtualized/issues/660 if (styleWidth) { node.style.width = styleWidth; } if (styleHeight) { node.style.height = styleHeight; } return {height, width}; } else { return {height: 0, width: 0}; } } _maybeMeasureCell() { const { cache, columnIndex = 0, parent, rowIndex = this.props.index || 0, } = this.props; // 如果快取中沒有資料 if (!cache.has(rowIndex, columnIndex)) { // 則計算對應元素的大小 const {height, width} = this._getCellMeasurements(); // 快取元素的大小 cache.set(rowIndex, columnIndex, width, height); // 通過上一篇文章的分析,可以得知 parent 是 Grid 元件 // 更新 Grid 元件的 _deferredInvalidate[Column|Row]Index,使其在掛載或更新的時候 re-render if ( parent && typeof parent.invalidateCellSizeAfterRender === 'function' ) { parent.invalidateCellSizeAfterRender({ columnIndex, rowIndex, }); } } } // ...
_maybeMeasureCell
方法最後會呼叫 invalidateCellSizeAfterRender
,從方法的 原始碼 上看,它只是更新了元件的 _deferredInvalidateColumnIndex
和 _deferredInvalidateRowIndex
的值,那呼叫它為什麼會觸發 Grid 的 re-render 呢?因為這兩個值被用到的地方是在 _handleInvalidatedGridSize
方法中,從其 原始碼 上看,它呼叫了 recomputeGridSize
方法(後文會提到這個方法)。而 _handleInvalidatedGridSize
方法是在元件的 componentDidMount
和 componentDidUpdate
的時候均會呼叫。
從上文可以知道,如果子元件是函式,則呼叫的時候還會傳遞 measure
引數,其值是 _measure
,實現如下:
// source/CellMeasurer/CellMeasurer.js // ... _measure = () => { const { cache, columnIndex = 0, parent, rowIndex = this.props.index || 0, } = this.props; // 計算對應元素的大小 const {height, width} = this._getCellMeasurements(); // 對比快取中的資料 if ( height !== cache.getHeight(rowIndex, columnIndex) || width !== cache.getWidth(rowIndex, columnIndex) ) { // 如果不相等,則重置快取 cache.set(rowIndex, columnIndex, width, height); // 並通知父元件,即 Grid 元件強制 re-render if (parent && typeof parent.recomputeGridSize === 'function') { parent.recomputeGridSize({ columnIndex, rowIndex, }); } } }; // ...
recomputeGridSize
方法時 Grid 元件的一個公開方法,用於重新計算元素的大小,並通過 forceUpdate
強制 re-render,其實現比較簡單,如果你有興趣瞭解,可以去檢視下其 原始碼 。
至此, CellMeasurer
元件的實現就分析完結了。如上文所說, CellMeasurer
元件要和 CellMeasurerCache
元件搭配使用,因而接下來我們快速看下 CellMeasurerCache
元件的實現:
// source/CellMeasurer/CellMeasurerCache.js // ... // KeyMapper 是一個函式,根據行索引和列索引返回對應資料的唯一 ID // 這個 ID 會作為 Cache 的 key // 預設的唯一標識是 `${rowIndex}-${columnIndex}`,見下文的 defaultKeyMapper type KeyMapper = (rowIndex: number, columnIndex: number) => any; export const DEFAULT_HEIGHT = 30; export const DEFAULT_WIDTH = 100; // ... type Cache = { [key: any]: number, }; // ... _cellHeightCache: Cache = {}; _cellWidthCache: Cache = {}; _columnWidthCache: Cache = {}; _rowHeightCache: Cache = {}; _columnCount = 0; _rowCount = 0; // ... constructor(params: CellMeasurerCacheParams = {}) { const { defaultHeight, defaultWidth, fixedHeight, fixedWidth, keyMapper, minHeight, minWidth, } = params; // 儲存相關值或標記位 this._hasFixedHeight = fixedHeight === true; this._hasFixedWidth = fixedWidth === true; this._minHeight = minHeight || 0; this._minWidth = minWidth || 0; this._keyMapper = keyMapper || defaultKeyMapper; // 獲取預設的高寬 this._defaultHeight = Math.max( this._minHeight, typeof defaultHeight === 'number' ? defaultHeight : DEFAULT_HEIGHT, ); this._defaultWidth = Math.max( this._minWidth, typeof defaultWidth === 'number' ? defaultWidth : DEFAULT_WIDTH, ); // ... } // ... hasFixedHeight(): boolean { return this._hasFixedHeight; } hasFixedWidth(): boolean { return this._hasFixedWidth; } // ... // 根據索引獲取對應的列寬 // 可用於 Grid 元件的 columnWidth 屬性 columnWidth = ({index}: IndexParam) => { const key = this._keyMapper(0, index); return this._columnWidthCache.hasOwnProperty(key) ? this._columnWidthCache[key] : this._defaultWidth; }; // ... // 根據行索引和列索引獲取對應 cell 元素的高度 getHeight(rowIndex: number, columnIndex: number = 0): number { if (this._hasFixedHeight) { return this._defaultHeight; } else { const key = this._keyMapper(rowIndex, columnIndex); return this._cellHeightCache.hasOwnProperty(key) ? Math.max(this._minHeight, this._cellHeightCache[key]) : this._defaultHeight; } } // 根據行索引和列索引獲取對應 cell 元素的寬度 getWidth(rowIndex: number, columnIndex: number = 0): number { if (this._hasFixedWidth) { return this._defaultWidth; } else { const key = this._keyMapper(rowIndex, columnIndex); return this._cellWidthCache.hasOwnProperty(key) ? Math.max(this._minWidth, this._cellWidthCache[key]) : this._defaultWidth; } } // 是否有快取資料 has(rowIndex: number, columnIndex: number = 0): boolean { const key = this._keyMapper(rowIndex, columnIndex); return this._cellHeightCache.hasOwnProperty(key); } // 根據索引獲取對應的行高 // 可用於 List/Grid 元件的 rowHeight 屬性 rowHeight = ({index}: IndexParam) => { const key = this._keyMapper(index, 0); return this._rowHeightCache.hasOwnProperty(key) ? this._rowHeightCache[key] : this._defaultHeight; }; // 快取元素的大小 set( rowIndex: number, columnIndex: number, width: number, height: number, ): void { const key = this._keyMapper(rowIndex, columnIndex); if (columnIndex >= this._columnCount) { this._columnCount = columnIndex + 1; } if (rowIndex >= this._rowCount) { this._rowCount = rowIndex + 1; } // 快取單個 cell 元素的高寬 this._cellHeightCache[key] = height; this._cellWidthCache[key] = width; // 更新列寬或行高的快取 this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); } // 更新列寬或行高的快取,用於糾正預估值的計算 _updateCachedColumnAndRowSizes(rowIndex: number, columnIndex: number) { if (!this._hasFixedWidth) { let columnWidth = 0; for (let i = 0; i < this._rowCount; i++) { columnWidth = Math.max(columnWidth, this.getWidth(i, columnIndex)); } const columnKey = this._keyMapper(0, columnIndex); this._columnWidthCache[columnKey] = columnWidth; } if (!this._hasFixedHeight) { let rowHeight = 0; for (let i = 0; i < this._columnCount; i++) { rowHeight = Math.max(rowHeight, this.getHeight(rowIndex, i)); } const rowKey = this._keyMapper(rowIndex, 0); this._rowHeightCache[rowKey] = rowHeight; } } // ... function defaultKeyMapper(rowIndex: number, columnIndex: number) { return `${rowIndex}-${columnIndex}`; }
對於 _updateCachedColumnAndRowSizes
方法需要補充說明一點的是,通過上一篇文章的分析,我們知道在元件內不僅需要去計算總的列寬和行高的( CellSizeAndPositionManager#getTotalSize
方法) ,而且需要計算 cell 元素的大小( CellSizeAndPositionManager#_cellSizeGetter
方法)。在 cell 元素被渲染之前,用的是預估的列寬值或者行高值計算的,此時的值未必就是精確的,而當 cell 元素渲染之後,就能獲取到其真實的大小,因而快取其真實的大小之後,在元件的下次 re-render 的時候就能對原先預估值的計算進行糾正,得到更精確的值。
demo的完整程式碼戳此: ReactVirtualizedList
總結
List
元件通過和 AutoSizer
元件以及 CellMeasurer
元件的組合使用,很好的優化了 List
元件自身對元素動態高度的支援。但從上文分析可知, CellMeasurer
元件會在其初次掛載( mount
)和更新( update
)的時候通過 _maybeMeasureCell
方法去更新自身的大小,如果 cell 元素只是渲染純文字,這是可以滿足需求的,但 cell 元素是渲染圖文呢?
因為圖片存在網路請求,因而在元件掛載和更新時,圖片未必就一定載入完成了,因而此時獲取到的節點大小是不準確的,就有可能導致內容重疊:
這種情況下,我們可以根據專案的實際情況做一些佈局上的處理,比如去掉 border
,適當增加 cell 元素的 padding
或者 margin
等(
CellMeasurer
的子元件換成函式 。
上文已經說過,如果子元件是函式,則呼叫的時候會傳遞一個函式 measure
作為引數,這個函式所做的事情就是重新計算對應 cell 元素的大小,並使 Grid
元件 re-render。因而,我們可以將這個引數繫結到 img
的 onLoad
事件中,當圖片載入完成時,就會重新計算對應 cell 元素的大小,此時,獲取到的節點大小就是比較精確的值了:
// ... renderRow({ index, key, style, parent }) { // 上一篇分析過,List 是 columnCount 為 1 的 Grid 元件, // 因而 columnIndex 是固定的 0 return ( <CellMeasurer key={key} cache={this.cache} parent={parent} columnIndex={0} rowIndex={index}> { ({measure}) => ( <div style={style} className="row"> <div>{`${text}`}</div> <img src={src} onLoad={measure}> </div> ) } </CellMeasurer> ); } // ...
渲染圖文demo的完整程式碼戳此: ReactVirtualizedList with image
<本文完>