We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
本文源码分析基于 v9.20.1
react-virtualized 是一个功能非常强大的库,其提供了 Grid、List、Table、Collection 以及 Masonry 等 五个主要组件,覆盖了常见场景下的长列表数据渲染。react-virtualized 提供了一个 Playground,如果你对其组件很感兴趣,可以去 playground 体验一下。
Grid
List
Table
Collection
Masonry
本文将着重分析其在虚拟列表上的实现,对于其它组件暂不讨论。
react-virtualized 在虚拟列表上的实现上,支持列表项的动态高度和固定高度,与之相关的两个主要属性有 estimatedRowSize 和 rowHeight。rowHeight 用于设置列表项的高度:
estimatedRowSize
rowHeight
(index: number): number
如果不知道 rowHeight 的值,则可用 estimatedRowSize 属性给列表项元素一个预估的高度,这样就能依赖预估高度计算列表内容的总高度,并且总高度随着列表项的渲染而渐进调整。这个在列表项是动态高度的场景下很有用,可以初始化内容的总高度以撑开容器元素,使其可在垂直方向滚动。
初步了解这两个属性之后,我们先看下其采用的 DOM 结构。
要了解组件的 DOM 结构,先看组件的 render 方法:
render
// source/List/List.js // ... render() { const {className, noRowsRenderer, scrollToIndex, width} = this.props; const classNames = cn('ReactVirtualized__List', className); 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} /> ); } _cellRenderer ({ parent, rowIndex, style, isScrolling, isVisible, key, }: CellRendererParams) { // 渲染列表项(cell)组件 const {rowRenderer} = this.props; // ... return rowRenderer({ index: rowIndex, style, isScrolling, isVisible, key, parent, }) } _setRef= (ref: ?React.ElementRef<typeof Grid>) => { // 设置组件的 reference this.Grid = ref; } _onScroll = ({clientHeight, scrollHeight, scrollTop}: GridScroll) => { const {onScroll} = this.props; // 调用 onScroll 回调 onScroll({clientHeight, scrollHeight, scrollTop}); }; // ...
从上述代码可以看出,List 组件其实是一个列数(columnCount)为 1 的 Grid 组件。
columnCount
从 Grid demo 来看,渲染出来的结果有点类似去掉了头的 table。当然,react-virtualized 提供了正规的 Table 组件,虽然其内部实现上依然是 Grid。Grid 组件在控制行列的渲染上,主要依赖了 cellRenderer 、columnWidth、columnCount、rowCount 以及 rowHeight 等几个属性,具体说明见文档。
cellRenderer
columnWidth
rowCount
我们粗略看下 Grid 组件的 render 方法:
// source/Grid/Grid.js // ... render () { const { height, width, autoContainerWidth, noContentRenderer, style, containerStyle, // ... } = this.props; const {instanceProps, needToResetStyleCache} = this.state; // ... const gridStyle: Object = { // ... }; // ... // 计算需要渲染的子元素 this._calculateChildrenToRender(this.props, this.state); // 计算内容的总宽度和高度 const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); // ... const childrenToDisplay = this._childrenToDisplay; const showNoContentRenderer = childrenToDisplay.length === 0 && height > 0 && width > 0; return ( { // 滚动容器元素 } <div ref={this._setScrollingContainerRef} {...containerProps} aria-label={this.props['aria-label']} aria-readonly={this.props['aria-readonly']} className={cn('ReactVirtualized__Grid', className)} id={id} onScroll={this._onScroll} role={role} style={{ ...gridStyle, ...style, }} tabIndex={tabIndex}> { // 可滚动区域 } {childrenToDisplay.length > 0 && ( <div className="ReactVirtualized__Grid__innerScrollContainer" role={containerRole} style={{ width: autoContainerWidth ? 'auto' : totalColumnsWidth, height: totalRowsHeight, maxWidth: totalColumnsWidth, maxHeight: totalRowsHeight, overflow: 'hidden', pointerEvents: isScrolling ? 'none' : '', position: 'relative', ...containerStyle, }}> { // 需要渲染的子元素 childrenToDisplay } </div> )} { // 没有需要渲染的子元素时,则渲染 placeholder 内容 showNoContentRenderer && noContentRenderer() } </div> ); } // ...
childrenToDisplay 是可视区域内被渲染的元素列表。 columnSizeAndPositionManager 和 rowSizeAndPositionManager 均是 ScalingCellSizeAndPositionManager 类的实例,分别用于管理 cell 元素的大小(列宽和行高)和位置偏移:
childrenToDisplay
columnSizeAndPositionManager
rowSizeAndPositionManager
ScalingCellSizeAndPositionManager
// source/Grid/Grid.js // ... constructor(props: Props) { super(props); const columnSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ // 总的数据个数 cellCount: props.columnCount, // 根据索引获取 cell 元素的列宽 cellSizeGetter: params => Grid._wrapSizeGetter(props.columnWidth)(params), // 预估的列宽大小 estimatedCellSize: Grid._getEstimatedColumnSize(props), }); const rowSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ cellCount: props.rowCount, // 根据索引获取 cell 元素的行高 cellSizeGetter: params => Grid._wrapSizeGetter(props.rowHeight)(params), // 预估的行高大小 estimatedCellSize: Grid._getEstimatedRowSize(props), }); this.state = { instanceProps: { columnSizeAndPositionManager, rowSizeAndPositionManager, // ... }, // ... // 是否要重置样式缓存 needToResetStyleCache: false, }; // ... } // ... static _wrapSizeGetter(value: CellSize): CellSizeGetter { return typeof value === 'function' ? value : () => (value: any); } // ... static _getEstimatedColumnSize(props: Props) { return typeof props.columnWidth === 'number' ? props.columnWidth : props.estimatedColumnSize; } static _getEstimatedRowSize(props: Props) { return typeof props.rowHeight === 'number' ? props.rowHeight : props.estimatedRowSize; } // ...
columnWidth 和 rowHeight 可以是固定值,也可以是函数。函数的签名是 ({ index: number }): number,从签名可以看出,函数需要根据索引(index)返回对应的列宽值或者行高值。
({ index: number }): number
index
得到了每个 cell 元素的预估的列宽和行高之后,就可以预估可滚动区域内的总大小了(内容的宽度和高度),接下来看下 getTotalSize 方法的实现:
getTotalSize
// source/Grid/utils/ScalingCellSizeAndPositionManager.js // ... export default class ScalingCellSizeAndPositionManager { // ... constructor({maxScrollSize = getMaxElementSize(), ...params}: Params) { this._cellSizeAndPositionManager = new CellSizeAndPositionManager(params); // 设置浏览器能支持的元素的大小极限值 // Chrome:1.67771e7 其它浏览器:1500000 this._maxScrollSize = maxScrollSize; } // ... getTotalSize(): number { return Math.min( this._maxScrollSize, this._cellSizeAndPositionManager.getTotalSize(), ); } } // ...
从上述代码可以看到,其实是调用了 CellSizeAndPositionManager 实例的 getTotalSize 方法,然后返回 _maxScrollSize 和 getTotalSize 方法返回值中的较小值:
CellSizeAndPositionManager
_maxScrollSize
// source/Grid/utils/CellSizeAndPositionManager.js // ... export default class CellSizeAndPositionManager { // 缓存 cell 元素的大小(水平方向为宽度,垂直方向为高度)和位置,以元素索引为 key // 对于垂直方向,offset 是对应 cell 元素的上边到第一个元素的上边的偏移距离 // 对于水平方向,offset 是对应 cell 元素的左边到第一个元素的左边的偏移距离 // 例如:this._cellSizeAndPositionData[1] = {size: 100, offset: 120} _cellSizeAndPositionData = {}; // 最后一个被计算过的 cell 元素的索引 // 索引小于该值的元素都被计算过了,反之没有,要用预估的大小 // 默认值是 -1 _lastMeasuredIndex = -1; // ... constructor({ cellCount, cellSizeGetter, estimatedCellSize, }: CellSizeAndPositionManagerParams) { this._cellSizeGetter = cellSizeGetter; this._cellCount = cellCount; this._estimatedCellSize = estimatedCellSize; } // ... // 返回最后一个被计算过元素的大小和偏移 // 如果没有就返回一个默认的初始值 getSizeAndPositionOfLastMeasuredCell(): SizeAndPositionData { return this._lastMeasuredIndex >= 0 ? this._cellSizeAndPositionData[this._lastMeasuredIndex] : { offset: 0, size: 0, }; } // 返回可滚动区域的总大小(高度或宽度) getTotalSize(): number { const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); // 已被渲染过的 cell 元素的总大小 const totalSizeOfMeasuredCells = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; // 未被渲染过的 cell 元素的总大小,用预估值进行计算 const numUnmeasuredCells = this._cellCount - this._lastMeasuredIndex - 1; const totalSizeOfUnmeasuredCells = numUnmeasuredCells * this._estimatedCellSize; return totalSizeOfMeasuredCells + totalSizeOfUnmeasuredCells; } // ... } // ...
如果 cell 元素的预估宽高均是 100,总数据个数是 200,那初始化时的预估的内容总宽度和总高度均是 (200 - (-1) - 1) * 100 = 20000,这样就可以撑开滚动容器元素。
知道了该库怎么预估初始化大小的,接下来看看它是怎么计算需要渲染的元素的。从上文可以知道,其主要是通过 _calculateChildrenToRender 方法来计算。接下来我们看下其关键部分的实现
_calculateChildrenToRender
// source/Grid/Grid.js // ... render () { // ... // 计算需要渲染的子元素 this._calculateChildrenToRender(this.props, this.state); // ... const childrenToDisplay = this._childrenToDisplay; return ( // ... { // 需要渲染的子元素 childrenToDisplay } // ... </div> ); } // ... _calculateChildrenToRender( props: Props = this.props, state: State = this.state, ) { const { cellRenderer, cellRangeRenderer, columnCount, deferredMeasurementCache, height, overscanColumnCount, overscanIndicesGetter, overscanRowCount, rowCount, width, isScrollingOptOut, } = props; const { scrollDirectionHorizontal, scrollDirectionVertical, instanceProps, } = state; /** * 如果设置了 scrollToRow 和 scrollToColumn,则会分别计算水平和垂直的初始偏移值 * 没有设置相关属性,则使用默认值 0 */ const scrollTop = this._initialScrollTop > 0 ? this._initialScrollTop : state.scrollTop; const scrollLeft = this._initialScrollLeft > 0 ? this._initialScrollLeft : state.scrollLeft; // 内部标志位:容器元素是否正在滚动 const isScrolling = this._isScrolling(props, state); // 保存需要渲染的元素 this._childrenToDisplay = []; // 根据容器元素的大小计算需要渲染元素的边界值 if (height > 0 && width > 0) { // 计算渲染可见的列元素的边界值 const visibleColumnIndices = instanceProps.columnSizeAndPositionManager.getVisibleCellRange( { containerSize: width, offset: scrollLeft, }, ); // 计算渲染可见的行元素的边界值 const visibleRowIndices = instanceProps.rowSizeAndPositionManager.getVisibleCellRange( { containerSize: height, offset: scrollTop, }, ); // 计算水平方向需要调整的偏移 const horizontalOffsetAdjustment = instanceProps.columnSizeAndPositionManager.getOffsetAdjustment( { containerSize: width, offset: scrollLeft, }, ); // 计算水平方向需要调整的偏移 const verticalOffsetAdjustment = instanceProps.rowSizeAndPositionManager.getOffsetAdjustment( { containerSize: height, offset: scrollTop, }, ); // ... /** * 根据滚动方向和设置的 overscanColumnCount/overscanRowCount 计算可视区域外 * 需要渲染元素的 startIndex 和 stopIndex **/ const overscanColumnIndices = overscanIndicesGetter({ direction: 'horizontal', cellCount: columnCount, overscanCellsCount: overscanColumnCount, scrollDirection: scrollDirectionHorizontal, startIndex: typeof visibleColumnIndices.start === 'number' ? visibleColumnIndices.start : 0, stopIndex: typeof visibleColumnIndices.stop === 'number' ? visibleColumnIndices.stop : -1, }); const overscanRowIndices = overscanIndicesGetter({ direction: 'vertical', cellCount: rowCount, overscanCellsCount: overscanRowCount, scrollDirection: scrollDirectionVertical, startIndex: typeof visibleRowIndices.start === 'number' ? visibleRowIndices.start : 0, stopIndex: typeof visibleRowIndices.stop === 'number' ? visibleRowIndices.stop : -1, }); // 获取各边界值 let columnStartIndex = overscanColumnIndices.overscanStartIndex; let columnStopIndex = overscanColumnIndices.overscanStopIndex; let rowStartIndex = overscanRowIndices.overscanStartIndex; let rowStopIndex = overscanRowIndices.overscanStopIndex; // 对计算缓存的处理,后续再讲 if (deferredMeasurementCache) { // ... } // 计算需要渲染的元素 this._childrenToDisplay = cellRangeRenderer({ cellCache: this._cellCache, cellRenderer, columnSizeAndPositionManager: instanceProps.columnSizeAndPositionManager, columnStartIndex, columnStopIndex, deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, isScrollingOptOut, parent: this, rowSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, rowStartIndex, rowStopIndex, scrollLeft, scrollTop, styleCache: this._styleCache, verticalOffsetAdjustment, visibleColumnIndices, visibleRowIndices, }); // 保存计算的边界值 this._columnStartIndex = columnStartIndex; this._columnStopIndex = columnStopIndex; this._rowStartIndex = rowStartIndex; this._rowStopIndex = rowStopIndex; } // ...
从上述代码可以看到,会先计算水平和垂直的偏移值(scrollTop 和 scrollLeft),然后根据对应的偏移值和容器元素的大小分别计算需要渲染元素的列边界值和行边界值。ScalingCellSizeAndPositionManager 类实例的 getVisibleCellRange 方法实际上是调用了 CellSizeAndPositionManager 类实例的对应方法,因为我们直接看后者实例的 getVisibleCellRange 方法的实现:
scrollTop
scrollLeft
getVisibleCellRange
// source/Grid/utils/CellSizeAndPositionManager.js // ... type GetVisibleCellRangeParams = { containerSize: number, offset: number, }; type SizeAndPositionData = { offset: number, size: number, }; // ... getVisibleCellRange(params: GetVisibleCellRangeParams): VisibleCellRange { let {containerSize, offset} = params; // 获取预估的总大小 const totalSize = this.getTotalSize(); if (totalSize === 0) { return {}; } // 计算水平或者垂直方向上的最大偏移 const maxOffset = offset + containerSize; // 根据 offset 找到其附近的列表项的索引值 const start = this._findNearestCell(offset); // 获取 start 对应元素的大小和偏移 const datum = this.getSizeAndPositionOfCell(start); offset = datum.offset + datum.size; // 初始化 stop let stop = start; // 如果 stop 小于总个数,则一直累加计算 start 之后的元素的偏移量 // 直到其值不小于 maxOffset,此时 stop 便对应可视区域的最后一个可见元素 while (offset < maxOffset && stop < this._cellCount - 1) { stop++; offset += this.getSizeAndPositionOfCell(stop).size; } // 返回 start 和 stop return { start, stop, }; } // ... // 根据索引获取对应元素的大小和偏移 getSizeAndPositionOfCell(index: number): SizeAndPositionData { if (index < 0 || index >= this._cellCount) { throw Error( `Requested index ${index} is outside of range 0..${this._cellCount}`, ); } // 如果 index 小于最后一次被计算过元素的索引,则直接从缓存中读取 if (index > this._lastMeasuredIndex) { // 获取最后一个被计算过元素的大小和偏移 let lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); let offset = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; for (var i = this._lastMeasuredIndex + 1; i <= index; i++) { // 根据索引获取对应元素的大小(高度或宽度) let size = this._cellSizeGetter({index: i}); // size 不是 number 则报错 if (size === undefined || isNaN(size)) { throw Error(`Invalid size returned for cell ${i} of value ${size}`); } else if (size === null) { // size 为 null 则重置为 0 this._cellSizeAndPositionData[i] = { offset, size: 0, }; this._lastBatchedIndex = index; } else { // 缓存元素的大小和偏移 this._cellSizeAndPositionData[i] = { offset, size, }; // 累加偏移量 offset += size; // 记录最后一次被计算过大小的元素的索引 this._lastMeasuredIndex = index; } } } // 返回元素的大小和偏移 return this._cellSizeAndPositionData[index]; } // ...
计算边界值之后,然后分别计算水平和垂直方向需要调整的偏移值,因为上文已经说过,浏览器对元素的大小是有一个极限值的,ScalingCellSizeAndPositionManager 类实例的 _maxScrollSize 属性保存了这个极限值(Chrome 是 1.67771e7,其它浏览器是 1500000)。如果通过 getTotalSize 方法得到的预估大小超过了极限值,则需要进行偏移差的调整;如果小于极限值,则不需要调整,对应的计算结果就是 0。
紧接着通过 overscanIndicesGetter 函数重新计算了 startIndex 和 endIndex 的值,因为如果设置了 overscanColumnCount 和 overscanRowCount 属性,就要考虑可是区域之外需要渲染的元素。overscanIndicesGetter 函数的实现比较简单,可以自定义,具体参考相关文档。
overscanIndicesGetter
startIndex
endIndex
overscanColumnCount
overscanRowCount
最后我们看下 cellRangeRenderer 函数的具体实现。cellRangeRenderer 是 Grid 组件的一个属性,用于根据给定的索引区间来渲染对应的 cell 元素。既然是属性,那就可以定制,具体见相关文档。这里我们只分析一下其默认的实现,即 defaultCellRangeRenderer:
cellRangeRenderer
defaultCellRangeRenderer
// source/Grid/defaultCellRangeRenderer.js // ... export default function defaultCellRangeRenderer({ cellCache, cellRenderer, columnSizeAndPositionManager, columnStartIndex, columnStopIndex, deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, isScrollingOptOut, parent, // Grid (or List or Table) rowSizeAndPositionManager, rowStartIndex, rowStopIndex, styleCache, verticalOffsetAdjustment, visibleColumnIndices, visibleRowIndices, }: CellRangeRendererParams) { // 缓存需要渲染的 cell 元素 const renderedCells = []; // 通过比较预估大小(getTotalSize)和 __maxScrollSize 判断是否需要调整大小 const areOffsetsAdjusted = columnSizeAndPositionManager.areOffsetsAdjusted() || rowSizeAndPositionManager.areOffsetsAdjusted(); // 如果没有滚动且没有调整大小,则可以缓存 cell 元素的 style const canCacheStyle = !isScrolling && !areOffsetsAdjusted; // 根据计算好的边界值进行遍历 // 外层循环遍历行 for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { // 根据索引获取对应元素的行高和垂直偏移 let rowDatum = rowSizeAndPositionManager.getSizeAndPositionOfCell(rowIndex); // 内层循环遍历列 for ( let columnIndex = columnStartIndex; columnIndex <= columnStopIndex; columnIndex++ ) { // 根据索引获取元素的列宽和水平偏移 let columnDatum = columnSizeAndPositionManager.getSizeAndPositionOfCell( columnIndex, ); // 判断元素是否在边界区域内 let isVisible = columnIndex >= visibleColumnIndices.start && columnIndex <= visibleColumnIndices.stop && rowIndex >= visibleRowIndices.start && rowIndex <= visibleRowIndices.stop; let key = `${rowIndex}-${columnIndex}`; let style; if (canCacheStyle && styleCache[key]) { // 从缓存读取样式 style = styleCache[key]; } else { // 从 MeasurementCache 中读取样式 if ( deferredMeasurementCache && !deferredMeasurementCache.has(rowIndex, columnIndex) ) { // ... } else { // 没有缓存则设置内联元素的样式 style = { height: rowDatum.size, left: columnDatum.offset + horizontalOffsetAdjustment, position: 'absolute', top: rowDatum.offset + verticalOffsetAdjustment, width: columnDatum.size, }; // 缓存样式 styleCache[key] = style; } } // cellRenderer 函数的参数 let cellRendererParams = { columnIndex, isScrolling, isVisible, key, parent, rowIndex, style, }; let renderedCell; /** * isScrollingOptOut: 是否在滚动停止时重新渲染可见的 cell 元素 * 相关 issue:https://github.com/bvaughn/react-virtualized/issues/1028 **/ if ( (isScrollingOptOut || isScrolling) && !horizontalOffsetAdjustment && !verticalOffsetAdjustment ) { // 满足条件则缓存已经渲染的 cell 元素 if (!cellCache[key]) { // 缓存 cellRenderer 函数的返回值 cellCache[key] = cellRenderer(cellRendererParams); } renderedCell = cellCache[key]; } else { renderedCell = cellRenderer(cellRendererParams); } if (renderedCell == null || renderedCell === false) { continue; } // ... renderedCells.push(renderedCell); } } // 返回需要渲染的元素 return renderedCells; }
到这里,列表怎么在初始化渲染时怎么获取到可视区域内需要被渲染的元素就基本讲清楚了。那么,当用户滚动时,是怎么改变可视区域内需要被渲染的元素的呢?
我们看一下 scroll 事件的处理函数:
scroll
// source/Grid/Grid.js // ... handleScrollEvent({ scrollLeft: scrollLeftParam = 0, scrollTop: scrollTopParam = 0, }: ScrollPosition) { // 小于 0 则返回,主要避免 iOS 上的弹性下拉产生负值导致页面闪烁 if (scrollTopParam < 0) { return; } // 主要通过 RAF 判断元素是否在滚动 // 如果没有,则将 isScrolling 置为 false this._debounceScrollEnded(); const {autoHeight, autoWidth, height, width} = this.props; const {instanceProps} = this.state; /** * 计算滚动条的宽度、可滚动区域的总大小以及 scrollLeft 和 scrollTop * 滚动条的宽度计算: * https://github.com/react-bootstrap/dom-helpers/blob/master/src/util/scrollbarSize.js **/ const scrollbarSize = instanceProps.scrollbarSize; const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); const scrollLeft = Math.min( Math.max(0, totalColumnsWidth - width + scrollbarSize), scrollLeftParam, ); const scrollTop = Math.min( Math.max(0, totalRowsHeight - height + scrollbarSize), scrollTopParam, ); if ( this.state.scrollLeft !== scrollLeft || this.state.scrollTop !== scrollTop ) { /** * 计算滚动的方向,相关常量定义见: * https://github.com/bvaughn/react-virtualized/blob/master/source/Grid/defaultOverscanIndicesGetter.js **/ const scrollDirectionHorizontal = scrollLeft !== this.state.scrollLeft ? scrollLeft > this.state.scrollLeft ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD : this.state.scrollDirectionHorizontal; const scrollDirectionVertical = scrollTop !== this.state.scrollTop ? scrollTop > this.state.scrollTop ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD : this.state.scrollDirectionVertical; // 新的 state const newState: $Shape<State> = { isScrolling: true, // 元素正在滚动 scrollDirectionHorizontal, scrollDirectionVertical, scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.OBSERVED, }; // 如果设置了 autoHeight & autoWidth 属性 // 则要配合 WindowScroller 高阶组件使用 if (!autoHeight) { newState.scrollTop = scrollTop; } if (!autoWidth) { newState.scrollLeft = scrollLeft; } newState.needToResetStyleCache = false; // 更新 state this.setState(newState); } // ... } // ... _onScroll = (event: Event) => { // See issue #404 for more information. if (event.target === this._scrollingContainer) { this.handleScrollEvent((event.target: any)); } }; // ...
当用户滚动时,会更改 state 中一些标记位的值以及 scrollX,比如 isScrolling,因而组件会重新渲染,进而会重新根据新的水平及垂直偏移去计算新的数据边界值,边界值变了,就会改变可视区域内需要被渲染的元素。
state
isScrolling
本文主要分析了 react-virtualized 组件在虚拟列表上的实现,通过上述分析,会发现其实现思路与我们之前分析的 react-tiny-virtual-list 组件大致相似。从 List 组件的 文档 以及官方示例的 源码 上看,其对动态高度的支持也是需要使用者“显示”地返回每个列表项的高度,因而在列表项被渲染时,该列表项的大小就已经通过内联的样式固定了。
react-virtualized
react-tiny-virtual-list
所以,其也会存在和 react-tiny-virtual-list 组件一样的问题:元素内容的重叠。那可以尽量避免这个问题呢?且看下回分解。
<本文完>
The text was updated successfully, but these errors were encountered:
No branches or pull requests
前言
react-virtualized 是一个功能非常强大的库,其提供了
Grid
、List
、Table
、Collection
以及Masonry
等 五个主要组件,覆盖了常见场景下的长列表数据渲染。react-virtualized 提供了一个 Playground,如果你对其组件很感兴趣,可以去 playground 体验一下。本文将着重分析其在虚拟列表上的实现,对于其它组件暂不讨论。
react-virtualized 在虚拟列表上的实现上,支持列表项的动态高度和固定高度,与之相关的两个主要属性有
estimatedRowSize
和rowHeight
。rowHeight
用于设置列表项的高度:(index: number): number
,此时列表项是动态高度的如果不知道
rowHeight
的值,则可用estimatedRowSize
属性给列表项元素一个预估的高度,这样就能依赖预估高度计算列表内容的总高度,并且总高度随着列表项的渲染而渐进调整。这个在列表项是动态高度的场景下很有用,可以初始化内容的总高度以撑开容器元素,使其可在垂直方向滚动。初步了解这两个属性之后,我们先看下其采用的 DOM 结构。
内部的 DOM 结构
要了解组件的 DOM 结构,先看组件的
render
方法:从上述代码可以看出,
List
组件其实是一个列数(columnCount
)为 1 的Grid
组件。从 Grid demo 来看,渲染出来的结果有点类似去掉了头的 table。当然,react-virtualized 提供了正规的 Table 组件,虽然其内部实现上依然是 Grid。Grid 组件在控制行列的渲染上,主要依赖了
cellRenderer
、columnWidth
、columnCount
、rowCount
以及rowHeight
等几个属性,具体说明见文档。我们粗略看下
Grid
组件的render
方法:childrenToDisplay
是可视区域内被渲染的元素列表。columnSizeAndPositionManager
和rowSizeAndPositionManager
均是ScalingCellSizeAndPositionManager
类的实例,分别用于管理 cell 元素的大小(列宽和行高)和位置偏移:columnWidth
和rowHeight
可以是固定值,也可以是函数。函数的签名是({ index: number }): number
,从签名可以看出,函数需要根据索引(index
)返回对应的列宽值或者行高值。得到了每个 cell 元素的预估的列宽和行高之后,就可以预估可滚动区域内的总大小了(内容的宽度和高度),接下来看下
getTotalSize
方法的实现:从上述代码可以看到,其实是调用了
CellSizeAndPositionManager
实例的getTotalSize
方法,然后返回_maxScrollSize
和getTotalSize
方法返回值中的较小值:如果 cell 元素的预估宽高均是 100,总数据个数是 200,那初始化时的预估的内容总宽度和总高度均是 (200 - (-1) - 1) * 100 = 20000,这样就可以撑开滚动容器元素。
计算需要渲染的元素
知道了该库怎么预估初始化大小的,接下来看看它是怎么计算需要渲染的元素的。从上文可以知道,其主要是通过
_calculateChildrenToRender
方法来计算。接下来我们看下其关键部分的实现从上述代码可以看到,会先计算水平和垂直的偏移值(
scrollTop
和scrollLeft
),然后根据对应的偏移值和容器元素的大小分别计算需要渲染元素的列边界值和行边界值。ScalingCellSizeAndPositionManager
类实例的getVisibleCellRange
方法实际上是调用了CellSizeAndPositionManager
类实例的对应方法,因为我们直接看后者实例的getVisibleCellRange
方法的实现:计算边界值之后,然后分别计算水平和垂直方向需要调整的偏移值,因为上文已经说过,浏览器对元素的大小是有一个极限值的,
ScalingCellSizeAndPositionManager
类实例的_maxScrollSize
属性保存了这个极限值(Chrome 是 1.67771e7,其它浏览器是 1500000)。如果通过getTotalSize
方法得到的预估大小超过了极限值,则需要进行偏移差的调整;如果小于极限值,则不需要调整,对应的计算结果就是 0。紧接着通过
overscanIndicesGetter
函数重新计算了startIndex
和endIndex
的值,因为如果设置了overscanColumnCount
和overscanRowCount
属性,就要考虑可是区域之外需要渲染的元素。overscanIndicesGetter
函数的实现比较简单,可以自定义,具体参考相关文档。最后我们看下
cellRangeRenderer
函数的具体实现。cellRangeRenderer
是 Grid 组件的一个属性,用于根据给定的索引区间来渲染对应的 cell 元素。既然是属性,那就可以定制,具体见相关文档。这里我们只分析一下其默认的实现,即defaultCellRangeRenderer
:到这里,列表怎么在初始化渲染时怎么获取到可视区域内需要被渲染的元素就基本讲清楚了。那么,当用户滚动时,是怎么改变可视区域内需要被渲染的元素的呢?
滚动处理
我们看一下
scroll
事件的处理函数:当用户滚动时,会更改
state
中一些标记位的值以及 scrollX,比如isScrolling
,因而组件会重新渲染,进而会重新根据新的水平及垂直偏移去计算新的数据边界值,边界值变了,就会改变可视区域内需要被渲染的元素。总结
本文主要分析了
react-virtualized
组件在虚拟列表上的实现,通过上述分析,会发现其实现思路与我们之前分析的react-tiny-virtual-list
组件大致相似。从List
组件的 文档 以及官方示例的 源码 上看,其对动态高度的支持也是需要使用者“显示”地返回每个列表项的高度,因而在列表项被渲染时,该列表项的大小就已经通过内联的样式固定了。所以,其也会存在和
react-tiny-virtual-list
组件一样的问题:元素内容的重叠。那可以尽量避免这个问题呢?且看下回分解。<本文完>
The text was updated successfully, but these errors were encountered: