本文源码分析基于 v9.20.1 以及本文 demo 的测试环境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
在 上一篇 文章中,我简单分析了 react-virtualized
的 List 组件是怎么实现虚拟列表的,在文章的最后,留下了一个问题:怎么尽量避免元素内容重叠的问题?本篇将进行简单分析。
react-virtualized
的 List 组件虽然存在上述所说的问题,但是它还是可以通过和其它组件的组合来做的更好, 尽量避免在渲染图文场景下的元素内容重叠问题。
在 Rendering large lists with React Virtualized 一文中介绍了怎么通过 react-virtualized 来做长列表数据的渲染优化,并详细介绍通过 AutoSizer
和 CellMeasurer
组件来实现 List 组件对列表项动态高度的支持:
这篇文章我们就分析一下这两个组件。
如果不使用 AutoSizer
组件,直接使用 List
组件可能如下:
使用 AutoSizer
组件之后,代码可能变成如下:
{ ({width, height}) => ( ) }
因为 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 ({children(childParams)}); } // ... _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
组件会根据自身的内容自动计算大小,需要配合 CellMeasurerCache
组件使用,这个组件主要缓存已计算过的 cell 元素的大小。
先修改一下代码,看看其使用方式:
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized"; class App extends Component { constructor() { ... this.cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: 180 }); } ... }
首先,我们创建了 CellMeasurerCache
实例,并设置了两个属性:
然后,我们需要修改 List
组件的 renderRow
方法以及 List
组件:
// ... renderRow({ index, key, style, parent }) { // 上一篇分析过,List 是 columnCount 为 1 的 Grid 组件, // 因而 columnIndex 是固定的 0 return (); } // ... { // 省略 }{ ({width, height}) => ( ) }
对于 List
组件有三个变动:
rowHeight
属性的值变成 this.cache.rowHeight
deferredMeasurementCache
属性,并且其值为 CellMeasurerCache
的实例 renderRow
方法返回的元素外用 CellMeasurer
组件包裹了一层 从 List
组件的 文档 看,并没有 deferredMeasurementCache
属性说明,但在上一篇文章分析过, List
组件的内部实现是基于 Grid
组件的:
// source/List/List.js // ... render() { //... return (); } // ...
而 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对于
_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
等(:blush: :blush: :blush: ),这是有点取巧的方式,那不取的方式是 将CellMeasurer
的子组件换成函数 。上文已经说过,如果子组件是函数,则调用的时候会传递一个函数
measure
作为参数,这个函数所做的事情就是重新计算对应 cell 元素的大小,并使Grid
组件 re-render。因而,我们可以将这个参数绑定到img
的onLoad
事件中,当图片加载完成时,就会重新计算对应 cell 元素的大小,此时,获取到的节点大小就是比较精确的值了:// ... renderRow({ index, key, style, parent }) { // 上一篇分析过,List 是 columnCount 为 1 的 Grid 组件, // 因而 columnIndex 是固定的 0 return ({ ({measure}) => ( ); } // ...) }{`${text}`}渲染图文demo的完整代码戳此: ReactVirtualizedList with image
<本文完>
参考
- Rendering large lists with React Virtualized
以上所述就是小编给大家介绍的《react-virtualized 组件的虚拟列表优化分析》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 我们 的支持!