dwqs / blog

:dog: :clap: :star2: Welcome to star

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

react-virtualized 组件的虚拟列表实现

dwqs opened this issue · comments

前言

本文源码分析基于 v9.20.1

react-virtualized 是一个功能非常强大的库,其提供了 GridListTableCollection 以及 Masonry 等 五个主要组件,覆盖了常见场景下的长列表数据渲染。react-virtualized 提供了一个 Playground,如果你对其组件很感兴趣,可以去 playground 体验一下。

本文将着重分析其在虚拟列表上的实现,对于其它组件暂不讨论。

react-virtualized 在虚拟列表上的实现上,支持列表项的动态高度和固定高度,与之相关的两个主要属性有 estimatedRowSizerowHeightrowHeight 用于设置列表项的高度:

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number,此时列表项是动态高度的

如果不知道 rowHeight 的值,则可用 estimatedRowSize 属性给列表项元素一个预估的高度,这样就能依赖预估高度计算列表内容的总高度,并且总高度随着列表项的渲染而渐进调整。这个在列表项是动态高度的场景下很有用,可以初始化内容的总高度以撑开容器元素,使其可在垂直方向滚动。

初步了解这两个属性之后,我们先看下其采用的 DOM 结构。

内部的 DOM 结构

要了解组件的 DOM 结构,先看组件的 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 组件。

Grid demo 来看,渲染出来的结果有点类似去掉了头的 table。当然,react-virtualized 提供了正规的 Table 组件,虽然其内部实现上依然是 Grid。Grid 组件在控制行列的渲染上,主要依赖了 cellRenderercolumnWidthcolumnCountrowCount 以及 rowHeight 等几个属性,具体说明见文档

我们粗略看下 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 是可视区域内被渲染的元素列表。
columnSizeAndPositionManagerrowSizeAndPositionManager 均是 ScalingCellSizeAndPositionManager 类的实例,分别用于管理 cell 元素的大小(列宽和行高)和位置偏移:

// 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;
  }
// ...

columnWidthrowHeight 可以是固定值,也可以是函数。函数的签名是 ({ index: number }): number,从签名可以看出,函数需要根据索引(index)返回对应的列宽值或者行高值。

得到了每个 cell 元素的预估的列宽和行高之后,就可以预估可滚动区域内的总大小了(内容的宽度和高度),接下来看下 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 方法,然后返回 _maxScrollSizegetTotalSize 方法返回值中的较小值:

// 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 方法来计算。接下来我们看下其关键部分的实现

// 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;
  }

// ...

从上述代码可以看到,会先计算水平和垂直的偏移值(scrollTopscrollLeft),然后根据对应的偏移值和容器元素的大小分别计算需要渲染元素的列边界值和行边界值。ScalingCellSizeAndPositionManager 类实例的 getVisibleCellRange 方法实际上是调用了 CellSizeAndPositionManager 类实例的对应方法,因为我们直接看后者实例的 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 函数重新计算了 startIndexendIndex 的值,因为如果设置了 overscanColumnCountoverscanRowCount 属性,就要考虑可是区域之外需要渲染的元素。overscanIndicesGetter 函数的实现比较简单,可以自定义,具体参考相关文档

最后我们看下 cellRangeRenderer 函数的具体实现。cellRangeRenderer 是 Grid 组件的一个属性,用于根据给定的索引区间来渲染对应的 cell 元素。既然是属性,那就可以定制,具体见相关文档。这里我们只分析一下其默认的实现,即 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 事件的处理函数:

// 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,因而组件会重新渲染,进而会重新根据新的水平及垂直偏移去计算新的数据边界值,边界值变了,就会改变可视区域内需要被渲染的元素。

总结

本文主要分析了 react-virtualized 组件在虚拟列表上的实现,通过上述分析,会发现其实现思路与我们之前分析的 react-tiny-virtual-list 组件大致相似。从 List 组件的 文档 以及官方示例的 源码 上看,其对动态高度的支持也是需要使用者“显示”地返回每个列表项的高度,因而在列表项被渲染时,该列表项的大小就已经通过内联的样式固定了。

所以,其也会存在和 react-tiny-virtual-list 组件一样的问题:元素内容的重叠。那可以尽量避免这个问题呢?且看下回分解。

<本文完>