全面解析react-window源码

2,947 阅读3分钟

react-window 是什么?

react-window其实就是一个利用虚拟列表技术来实现性能优化的npm包,可以简单的理解为如果你有十万条数据需要渲染,如果不考虑性能可以一次性的渲染出来。 但是大多数情况下,我们的屏幕的可视区域其实根本无法完全展示这十万条数据,所以大部分数据其实都是在可视区域外面,也就是类似隐藏在深海下,我们根本看不到的,所以其实我们也没有必要渲染,而是只渲染我们可以看到的可视区域里边的数据就可以了。

react-window其实就是按照这个原理实现的。

react-window 源码

react-window是使用flow检查类型的,至于对于flow不是很熟悉也没什么关系,基本上和typescript差不多。

FixedSizeList

FixedSizeList使用

FixedSizeList 是最简单,首先来看下如何使用

import {FixedSizeList as List} from 'react-window';

const Row = ({index, style}) => (
    <div style={style}>Row {index}</div>
);

const App = () => (
  <List
      // height是最外层div渲染的高度
      height={150}
      // 总条数
      itemCount={1000}
      // 每条的高度
      itemSize={35}
      // width是最外层div的宽度
      width={300}
  >
      {Row}
  </List>
);

FixedSizeList实现

我们已经看过FixedSizeList的声明式使用了,那么我们就可以带着问题看下react-window是如何实现的。

  1. 假如是你来实现类似的功能,你会如何实现?

假如是我,可能会采用类似架构

  1. react-window 会如何渲染什么样的结构?

可以看下源码里边,代码其实很简单,就是调用createElement,创建出合适的dom结构。

容器div绑定onscroll事件,在容器div里边再添加一个Inner div,作为内部元素的容器

return createElement(
    outerElementType || outerTagName || 'div',
    {
      className,
      // 绑定onScroll事件
      onScroll,
      ref: this._outerRefSetter,
      style: {
        position: 'relative',
        height,
        width,
        overflow: 'auto',
        WebkitOverflowScrolling: 'touch',
        willChange: 'transform',
        direction,
        ...style,
      },
    },
    // 内部元素容器
    createElement(innerElementType || innerTagName || 'div', {
      children: items,
      ref: innerRef,
      style: {
        height: isHorizontal ? '100%' : estimatedTotalSize,
        pointerEvents: isScrolling ? 'none' : undefined,
        width: isHorizontal ? estimatedTotalSize : '100%',
      },
    })
  );
  1. 我们通过监听onScroll事件,那么当onScroll事件发生后,我们是如何确定渲染那些数据的呢?

其实也很简单,其实就是我们在监听到onScroll事件的时候,获取到滚动的距离scrollTop之后,然后更新state中的offset状态。

代码如下:

// 不相关的代码已经删掉
_onScrollVertical = (event: ScrollEvent): void => {
      const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
      this.setState(prevState => {
      
        // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds.
        const scrollOffset = Math.max(
          0,
          Math.min(scrollTop, scrollHeight - clientHeight)
        );

        return {
          isScrolling: true,
          scrollDirection:
            prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
          scrollOffset,
          scrollUpdateWasRequested: false,
        };
      }, this._resetIsScrollingDebounced);
    };  

触发更新之后,会重新计算startIndex和stopIndex,重新渲染

重新计算函数如下:

 // 这里计算出需要渲染数据的起始和结束
    _getRangeToRender(): [number, number, number, number] {
      // itemCount 总条数,overscanCount表示多加载几条,就是除了可视区域之外,多加载几条
      const { itemCount, overscanCount } = this.props;
      const { isScrolling, scrollDirection, scrollOffset } = this.state;
      // 如果总数据是0
      if (itemCount === 0) {
        return [0, 0, 0, 0];
      }
      // getStartIndexForOffset这里是外面传入进来的
      const startIndex = getStartIndexForOffset(
        this.props,
        scrollOffset,
        this._instanceProps
      );
      // getStopIndexForStartIndex 也是外面传入进来的
      const stopIndex = getStopIndexForStartIndex(
        this.props,
        startIndex,
        scrollOffset,
        this._instanceProps
      );

      return [
        Math.max(0, startIndex - overscanBackward),
        Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
        startIndex,
        stopIndex,
      ];
    }

可以看到代码很简单,就是通过外部传入的getStartIndexForOffset和getStopIndexForStartIndex,重新计算出startIndex和stopIndex就可以了 现在让我们来看下这两个函数 getStartIndexForOffset

 // 根据滚动的位置计算起始索引
  getStartIndexForOffset: (
    { itemCount, itemSize }: Props<any>,
    offset: number
  ): number =>
    Math.max(
      0,
      Math.min(itemCount - 1, Math.floor(offset / ((itemSize: any): number)))
    ),

可以看到很简单,就是根据滚动的距离重新计算出初始值

然后我们再来看下getStopIndexForStartIndex

// 计算结束索引
  getStopIndexForStartIndex: (
    { direction, height, itemCount, itemSize, layout, width }: Props<any>,
    startIndex: number,
    scrollOffset: number
  ): number => {
    // TODO Deprecate direction "horizontal"
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
    const offset = startIndex * ((itemSize: any): number);
    const size = (((isHorizontal ? width : height): any): number);
    const numVisibleItems = Math.ceil(
      (size + scrollOffset - offset) / ((itemSize: any): number)
    );
    return Math.max(
      0,
      Math.min(
        itemCount - 1,
        startIndex + numVisibleItems - 1 // -1 is because stop index is inclusive
      )
    );
  },

这里和getStartIndexForOffset不同的就是我们把height加入进去了,其他的计算没什么区别

总结

我们只是分析了FixedSizeList,至于其它的VariableSizeGrid等其实原理差不多的,所以只需要搞懂FixedSizeList原理,其它都很简单了。

react-window 里边并没有对于onScroll进行防抖处理,如果涉及快速下拉就会涉及到大量计算,这些应该都可以优化。

而且前面计算过的startIndex和stopIndex应该也都可以进行缓存复用。