Skip to content
New issue

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

react-virtualized 组件的虚拟列表优化分析 #73

Open
dwqs opened this issue Nov 12, 2018 · 1 comment
Open

react-virtualized 组件的虚拟列表优化分析 #73

dwqs opened this issue Nov 12, 2018 · 1 comment

Comments

@dwqs
Copy link
Owner

dwqs commented Nov 12, 2018

前言

本文源码分析基于 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 来做长列表数据的渲染优化,并详细介绍通过 AutoSizerCellMeasurer 组件来实现 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 组件使用了一个固定高度,所以将 AutoSizerdisableHeight 设置成 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 组件有三个变动:

  1. rowHeight 属性的值变成 this.cache.rowHeight
  2. 新增了 deferredMeasurementCache 属性,并且其值为 CellMeasurerCache 的实例
  3. 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 方法是在组件的 componentDidMountcomponentDidUpdate 的时候均会调用。

从上文可以知道,如果子组件是函数,则调用的时候还会传递 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 等(:blush::blush::blush:),这是有点取巧的方式,那不取的方式是CellMeasurer 的子组件换成函数

上文已经说过,如果子组件是函数,则调用的时候会传递一个函数 measure 作为参数,这个函数所做的事情就是重新计算对应 cell 元素的大小,并使 Grid 组件 re-render。因而,我们可以将这个参数绑定到 imgonLoad 事件中,当图片加载完成时,就会重新计算对应 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

<本文完>

参考

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants