dwqs/blog

react-tiny-virtual-list的源码解读

dwqs opened this issue · 5 comments

dwqs commented

前言

本文源码分析基于 v2.2.0 以及本文 demo 的测试环境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1

文档来看,该库支持横向和纵向两个方向的滚动(通过 scrollDirection 属性设置,默认是垂直方向),我们选择 垂直方向 来分析。

另外有两个需要说明的一个属性是 itemSizeestimatedItemSizeitemSize 用于设置列表项的高度:

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number

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

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

内部的 DOM 结构

要了解组件的 DOM 结构,先看组件的 render 方法:

// src/index.tsx

// ...
const STYLE_WRAPPER: React.CSSProperties = {
  overflow: 'auto',
  willChange: 'transform',
  WebkitOverflowScrolling: 'touch',
};
const STYLE_INNER: React.CSSProperties = {
  position: 'relative',
  width: '100%',
  minHeight: '100%',
};
// ...

render () {
  const {
    // ...
    height,
    width,
    style,
    ...props
  } = this.props
  // ...
  // 可视区域内被渲染的元素列表
  const items: React.ReactNode[] = []
  // 滚动容器元素的内联样式
  const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width}
  // 可滚动区域的内联样式
  const innerStyle = {
      ...STYLE_INNER,
      // 根据 scrollDirection 设置可滚动区域的总高度(或宽度)
      [sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize(),
   }
   const items: React.ReactNode[] = []
  // ...
  
  return (
    <div ref={this.getRef} {...props} style={wrapperStyle}>
      <div style={innerStyle}>{items}</div>
    </div>
  )
  // ...
}
// ...

items 是可视区域内被渲染的元素列表。sizeAndPositionManager 是类 SizeAndPositionManager 的一个实例,用于管理列表及列表项的大小和位置偏移:

// src/index.tsx

// ...
itemSizeGetter = (itemSize: Props['itemSize']) => {
  return index => this.getSize(index, itemSize);
};

sizeAndPositionManager = new SizeAndPositionManager({
  // 总的数据个数
  itemCount: this.props.itemCount,
  // 根据索引获取列表项的大小
  itemSizeGetter: this.itemSizeGetter(this.props.itemSize),
  // 列表项的预估大小
  estimatedItemSize: this.getEstimatedItemSize(),
});

// ...
// 根据 itemSize 的数据类型返回列表项的大小
private getSize(index: number, itemSize) {
  if (typeof itemSize === 'function') {
    return itemSize(index);
  }

  return Array.isArray(itemSize) ? itemSize[index] : itemSize;
}

// ...
// 获取列表项的预估大小
private getEstimatedItemSize(props = this.props) {
  return (
    props.estimatedItemSize ||
    (typeof props.itemSize === 'number' && props.itemSize) ||
    50
  );
}

获取到预估大小之后,就能预估可滚动区域的总大小了:

// src/SizeAndPositionManager.tsx

export default class SizeAndPositionManager {
  // ...
  constructor({itemCount, itemSizeGetter, estimatedItemSize}: Options) {
    this.itemSizeGetter = itemSizeGetter;
    this.itemCount = itemCount;
    this.estimatedItemSize = estimatedItemSize;

    // 缓存 item 的大小(size)和位置偏移(offset),以元素索引为 key
    // offset 是对应列表项的上边框到第一个元素的上边框的偏移距离
    // 例如:this.itemSizeAndPositionData[1] = {size: 100, offset: 120}
    this.itemSizeAndPositionData = {};

    // 最后一个被计算过的元素索引
    // 索引小于该值的元素都被计算过了,反之没有,要用预估的大小
    // 默认值是 -1
    this.lastMeasuredIndex = -1;
  }
  
  // ...
  // 返回最后一个被计算过元素的大小和偏移
  // 如果没有就返回一个默认的初始值
  getSizeAndPositionOfLastMeasuredItem() {
    return this.lastMeasuredIndex >= 0
      ? this.itemSizeAndPositionData[this.lastMeasuredIndex]
      : {offset: 0, size: 0};
  }
  
  // 返回可滚动区域的总大小(高度或宽度)
  getTotalSize(): number {
    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();

    return (
      lastMeasuredSizeAndPosition.offset +
      lastMeasuredSizeAndPosition.size +
      (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
    );
  }
  
  // ...
}

如果列表项的预估高度是 100,总数据个数是 200,那初始化时的预估高度就是 (200 - (-1) - 1) * 100 = 20000,这样就可以撑开滚动容器元素。

计算 startIndex 和 endIndex

知道了该库怎么预估初始化高度的,接下来看看它是怎么计算 startIndexendIndex 的。我们继续看它的 render 方法:

// src/index.tsx

// ...
render () {
  const {
    // ...
    renderItem,
    overscanCount = 3,
    height,
    width,
    style,
    ...props
  } = this.props
  // ...
  const {offset} = this.state;
  const {start, stop} = this.sizeAndPositionManager.getVisibleRange({
    // 这里根据 scrollDirection 设置滚动容器元素的总高度(或宽度)
    containerSize: this.props[sizeProp[scrollDirection]] || 0,
    offset,
    overscanCount,
  });
  // 可视区域内被渲染的元素列表
  const items: React.ReactNode[] = []
  // 滚动容器元素的内联样式
  const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width}
  // 可滚动区域的内联样式
  const innerStyle = {
      ...STYLE_INNER,
      // 根据 scrollDirection 设置可滚动区域的总高度(或宽度)
      [sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize(),
  }
  // ...
  
  // ...
  if (typeof start !== 'undefined' && typeof stop !== 'undefined') {
     for (let index = start; index <= stop; index++) {
      // ...
      
      items.push(
        renderItem({
          index,
          // 设置每个列表项元素的内联 style
          style: this.getStyle(index, false),
        }),
      );
     }

     // ...
  }
  // ...
  
  return (
    <div ref={this.getRef} {...props} style={wrapperStyle}>
      <div style={innerStyle}>{items}</div>
    </div>
  )
  // ...
}

// ...
// 返回每个列表项的内联样式
getStyle (index: number, sticky: boolean) {
  const style = this.styleCache[index];
    // 如果有缓存了,直接返回
    if (style) {
      return style;
    }

    const {scrollDirection = DIRECTION.VERTICAL} = this.props;
    // 根据 index,计算对应元素的大小和位置偏移
    const {
      size,
      offset,
    } = this.sizeAndPositionManager.getSizeAndPositionForIndex(index);
    
    // 根据 sticky 返回对应元素的 postion
    // sticky 决定了列表项元素的 postion 是 absolute 还是 sticky
    return (this.styleCache[index] = sticky
      ? {
          ...STYLE_STICKY_ITEM,
          [sizeProp[scrollDirection]]: size,
          [marginProp[scrollDirection]]: offset,
          [oppositeMarginProp[scrollDirection]]: -(offset + size),
          zIndex: 1,
        }
      : {
          ...STYLE_ITEM,
          // 根据 scrollDirection 设置列表项元素的 height(或 width)
          [sizeProp[scrollDirection]]: size,
          // 根据 scrollDirection 设置列表项元素的 top(或 left)
          [positionProp[scrollDirection]]: offset,
        });
}
// ...

从上述简化后的关键代码可以看到,该库会调用 getVisibleRange 方法来计算 startstop(即 startIndexendIndex),然后就可以利用这两个边界值来计算可视区域渲染的元素了。这里的关键方法是 getVisibleRange,它有三个参数:containerSizeoverscanCount 以及 offset,前两个都是通过 props 读取的,offset 是从 state 中读取的:

  // ...
  readonly state: State = {
    offset:
      this.props.scrollOffset ||
      (this.props.scrollToIndex != null &&
        this.getOffsetForIndex(this.props.scrollToIndex)) ||
      0,
    scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED,
  };
  // ...

如果没有设置 scrollOffsetscrollToIndex 属性,offset 的默认值是 0。从文档来看,scrollOffset 是设置滚动容器元素默认的垂直(或水平)偏移,scrollToIndex 是设置默认滚动到哪个元素。如果设置了 scrollToIndex,则会调用 getOffsetForIndex 方法获取到该索引对应元素的偏移,因而可以认为 offset 是滚动容器元素的垂直/水平偏移,即 scrollTop/scrollLeft 的值。

接着看 getVisibleRange 的实现:

// src/SizeAndPositionManager.tsx

// ...
getVisibleRange({
    containerSize,
    offset,
    overscanCount,
  }: {
    containerSize: number;
    offset: number;
    overscanCount: number;
  }): {start?: number; stop?: number} {
    // 获取预估的总大小
    const totalSize = this.getTotalSize();
    
    if (totalSize === 0) {
      return {};
    }
    
    // 计算最大偏移
    const maxOffset = offset + containerSize;
    // 根据 offset 找到其附近的列表项的索引值
    let start = this.findNearestItem(offset);

    if (typeof start === 'undefined') {
      throw Error(`Invalid offset ${offset} specified`);
    }
    
    // 获取 start 对应元素的大小和偏移
    const datum = this.getSizeAndPositionForIndex(start);
    offset = datum.offset + datum.size;
    
    // 初始化 stop
    let stop = start;
    
    // 如果 stop 小于总个数,则一直累加计算 start 之后的元素的偏移量
    // 直到其值不小于 maxOffset,此时 stop 便对应可视区域的最后一个可见元素
    while (offset < maxOffset && stop < this.itemCount - 1) {
      stop++;
      offset += this.getSizeAndPositionForIndex(stop).size;
    }
    
    // 如果设置了缓冲值 overscanCount
    // 则重新计算 start 和 stop
    if (overscanCount) {
      start = Math.max(0, start - overscanCount);
      stop = Math.min(stop + overscanCount, this.itemCount - 1);
    }
    
    // 返回 start 和 stop
    return {
      start,
      stop,
    };
  }
// ...

// 根据索引获取对应元素的大小和偏移
getSizeAndPositionForIndex(index: number) {
  if (index < 0 || index >= this.itemCount) {
    throw Error(
      `Requested index ${index} is outside of range 0..${this.itemCount}`,
    );
  }
 
  // 如果 index 小于最后一次被计算过元素的索引,则直接从缓存中读取
  if (index > this.lastMeasuredIndex) {
    // 获取最后一个被计算过元素的大小和偏移
    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
    let offset =
      lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;

    for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
      // 根据索引获取对应元素的大小(高度或宽度)
      // 值的计算根据 itemSize prop 的类型不同而不同
      const size = this.itemSizeGetter(i);

      if (size == null || isNaN(size)) {
        throw Error(`Invalid size returned for index ${i} of value ${size}`);
      }
      
      // 缓存元素的大小和偏移    
      this.itemSizeAndPositionData[i] = {
        offset,
        size,
      };
      
      // 累加偏移量    
      offset += size;
    }
    
    // 记录最后一次被计算过大小的元素的索引
    this.lastMeasuredIndex = index;
  }
  
  // 返回元素的大小和偏移
  return this.itemSizeAndPositionData[index];
}  

到这里,列表怎么在初始化渲染时怎么获取到可视区域内需要被渲染的元素就基本讲清楚了。那么,当用户滚动时,是怎么改变可视区域内需要被渲染的元素的呢?

滚动处理

我们看一下 scroll 事件的处理函数:

// src/index.tsx

// ...
// 滚动容器元素的 sroll 事件处理器
private handleScroll = (event: UIEvent) => {
  const {onScroll} = this.props;
  
  // 获取滚动容器元素的 scrollTop/scrollLeft 
  const offset = this.getNodeOffset();

  if (
    offset < 0 ||
    this.state.offset === offset ||
    event.target !== this.rootNode
   ) {
      return;
   }
 
  // 更新 state,使组件进行 re-render    
  this.setState({
    offset,
    scrollChangeReason: SCROLL_CHANGE_REASON.OBSERVED,
  });

  if (typeof onScroll === 'function') {
      onScroll(offset, event);
  }
};

private getNodeOffset() {
  const {scrollDirection = DIRECTION.VERTICAL} = this.props;
  // 根据 scrollDirection 返回容器元素的 scrollTop/scrollLeft   
  return this.rootNode[scrollProp[scrollDirection]];
}
// ...

当用户滚动时,会更改 offset 值,因而组件会重新渲染,进而会重新根据新的 offset 去计算新的 startstop 值。startstop 的值变了,就会改变可视区域内需要被渲染的元素。

如何处理列表项的动态高度?

要处理列表项的动态高度,关键在于 itemSize 属性。itemSize 它用于设置列表项的高度:

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number

在列表项是动态高度的场景下,itemSize 的值或是一个包含所有列表项高度的数据,或者一个根据索引返回类表项高度的函数。如果是数组,则需要知道每个列表项的高度或者列表项的高度有一定的规律,这种场景是非常受限的;如果是函数,只需要返回一个高度值就行,但元素未渲染到页面之前是无法得知其高度的,这个时候可以基于项目的实际情况,给列表项一个预估的高度:estimatedItemSize。此外,还需要在每个列表项的大小发生改变时调用 recomputeSizes (见 recomputeSizes):

// src/index.tsx

// ...
recomputeSizes(startIndex = 0) {
  // 清空样式缓存
  this.styleCache = {};
  this.sizeAndPositionManager.resetItem(startIndex);
}
  
  //...
  
  
// src/SizeAndPositionManager.tsx  

// ...
// 重置 lastMeasuredIndex 的值
resetItem (index) {
  this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1);   
}
// ...

上文说过,lastMeasuredIndex 是最后一个被计算过大小的元素的索引。假设初始化渲染时的索引区间是 [0, 8],那在渲染完成之后,lastMeasuredIndex 的值是 8,当索引为 5 的元素的大小改变之后,那么 索引不小于 5 的所有元素的大小和偏移都需要重新计算,因为需要将 lastMeasuredIndex 的值重置为 4。

在渲染可是区域的元素时,我们可以缓存被渲染过元素的大小,当元素再次被渲染时,就可以直接通过缓存读取。

从上文可知,每个列表项都有内联的 style,会设置元素的 height 以及定位信息,而 height 是通过 itemSize 属性返回的,也就是说,该库对动态高度的支持也是需要使用者“显示”地返回每个列表项的高度,因而在列表项被渲染时,该列表项的高度就已经通过内联的样式固定了。

而当元素实际渲染的内容偏少时,那其内容高度可能会小于给定的高度,就会造成大量的留白空间:

留白

当元素实际渲染的内容偏多时,那其内容高度可能会大于给定的高度,就会造成内容的重叠:

内容重叠

demo的完整代码戳此:TinyVirtualList

列表项在渲染图片混合的场景下,内容重叠会更容易出现。因为图片存在网络请求,组件内部并没有相关的自我调整机制,而列表项在渲染时就给定了高度,这种场景下,内容重叠就很容易出现了。

总结

本文主要分析了虚拟组件库 react-tiny-virtual-list 的实现,经过上述分析,我们可以知道,该库实现虚拟列表的主要原理是根据 stateoffset 值(即滚动容器元素的 scrollTop/scrollLeft 值)先计算出可视区域内第一个元素的 start 值,然后根据 start 对应元素的 offset 以及容器元素的大小,计算出当前可视区域内最后一个可见元素的索引,即 stop 值。有了 startstop 值,就可以改变可视区域需要渲染的内容了。

在处理动态高度时,我分析了其不足之处,并通过一个 Demo 简单分析了在项目中如何使用它。此外,如果你需要使用这个组件,下面两个问题可能也是需要你考虑的:

One More Thing

原本下一篇文章想分享 react-window@1.2.1 组件的虚拟列表实现原理,但发现其与 react-tiny-virtual-list 组件无论是在 DOM 的布局上还是 start 以及 stop 的计算规则上,实现思路基本是一样的,所以就不展开细讲了。这里列举部分我关注到的不同点:

  • react-window 不仅支持虚拟列表,还支持虚拟网格(Virtual Grid),见 demo
  • react-window 可以自定滚动容器元素以及内容容器元素的标签,二者的默认值都是 div
  • 对于 onItemsRenderedonScroll,react-window 在实现上通过 memoize-one 实现了计算缓存,而 react-tiny-virtual-list 则是直接调用
  • react-window 组件的 itemSize 仅支持数值或函数,不支持数组
  • 在列表项的渲染上,react-tiny-virtual-list 是通过 renderItem 回调,而 react-window 是通过 React.createElement,相对而言,后者相对受限

基于 react-window 写了一个渲染图文的demo,代码戳此:react-window

<本文完>

👍

请问一下当滑动过快的时候,页面会空白,这种如何解决?

看了下好像并没有解决动态高度的问题。
以我的理解,estimatedItemSize 的作用是用来设置还未加载过的item的高度,以便组件好处理一些滚动相关的内容。
但是真正有效的还是用户手动传入itemSize。
然而实际情况中用户是不知道item高度的,以文字为例

  1. 初始化时,文字过多时会换行,导致每个item的高度不固定。
  2. 用户改变窗口宽度时,原本是一行的文字可能变为两行。

@blueYufisher

请问一下当滑动过快的时候,页面会空白,这种如何解决?

可以初始化内容的总高度
overscanCount | Number | | Number of extra buffer items to render above/below the visible items. Tweaking this can help reduce scroll flickering on certain browsers/devices.