likes
comments
collection
share

Ant Design 虚拟化列表实现原理源码分析

作者站长头像
站长
· 阅读数 5

依然是废话开头

我这个b呢其实初学前端时就接触虚拟化列表,当时在参加一个全国开发竞赛,项目系统的大概内容就是将数据库中的植株点数据展示在表格和地图上

但是问题就来了,地图上展示肯定就得将一定范围内的数据点展示出来,而当时我这个b还年轻,还不知道应该用arcgis的数据库去上传要素图层,而后端想判断位置和范围又显得过于复杂,所以我的决定是直接从数据库调取所有数据,然后数据总共有一万条的植株点信息和两千条的道路信息,当把它展示在地图上时,还没有产生多大的渲染压力

Ant Design 虚拟化列表实现原理源码分析

但是当我去点开表格时,初始加载和滚动列表,都形成了极大的卡顿,一万条的植株点信息形成了一万个Dom节点,给GPU渲染造成了极大压力,后来我去搜索解决方案,找到了虚拟化表格这种方式(当然还有分页式表格,但我觉得不够优雅,所以完全不是后端的问题哈),并且成功的解决了以上问题,当时我还特意去搜索了一下它的实现原理,同志们可以先参照这篇文章:blog.csdn.net/m0_64023259…

但是当时我却对它实际的代码实现没有很深究,而现在在antd社区贡献代码的时候看到了rc-virtual-list的源码,发现还是特别有意思的

Ant Design 虚拟化列表实现原理源码分析

rc-virtual-list 源码分析

首先ant design的虚拟化列表的实现是基于rc-virtual-list这个包的,而这个包的开发人员其实也是antd的成员,因此我们直接对rc-virtual-list进行分析

rc-virtual-list的核心代码在List.tsx文件中,具体实现是这个方法:

export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) { ... }

Dom节点渲染原理

老规矩,分析一个代码先从它渲染的Dom上分析,再倒推它数据的变化,以下代码有选择性省略

    <div  {/** 此处省略 */} >
      <ResizeObserver onResize={onHolderResize}>
        <Component
          className={`${prefixCls}-holder`}
          {/** 此处省略 */}
          ref={componentRef}
          onScroll={onFallbackScroll}
        >
          <Filler
            height={scrollHeight}
            offsetX={offsetLeft}
            offsetY={fillerOffset}
            scrollWidth={scrollWidth}
            {/** 此处省略 */}
          >
            {listChildren}
          </Filler>
        </Component>
      </ResizeObserver>

      {inVirtual && scrollHeight > height && (
        <ScrollBar
          // if need cache height, just use last scrollTop
          scrollOffset={offsetTop}
          scrollRange={scrollHeight}
          onScroll={onScrollBar}
          onStartMove={onScrollbarStartMove}
          onStopMove={onScrollbarStopMove}
          {/** 此处省略 */}
        />
      )}
      {/** 此处省略 */}
    </div>

根据这个代码其实我们可推测个大概,我们配合下图进行食用: Ant Design 虚拟化列表实现原理源码分析 rc-virtual-list-holder是整个列表的可视区范围,而其的子div是整个滚动范围,可以看到它的高度大大超过了200px,滚动时其实就是滚动它,而这个子div下的rc-virtual-list-holder-inner就是实际渲染的Dom节点,它的高度取决于内部节点的内容,并且使用css transform对它进行垂直方向的位移,保证其父节点滚动时它也一直在可视区范围内

偏移高度offsetTop计算原理

分析完它Dom渲染的原理后我们再分析其js代码,再此之前我们挂张图,给同志们留个它渲染机制的印象

Ant Design 虚拟化列表实现原理源码分析

根据上面的科普我们知道,虚拟化表格的滚动加载对应节点,肯定是要依赖于监听滚动事件的,而在`Dom`这块的代码中看到了滚动事件的监听,那我们直接跳过去看看这个事件做了什么:
  <Component {省略} onScroll={onFallbackScroll} >

这个方法从原生事件中拿到了scrollTop,并调用了syncScrollTop(newScrollTop),其它代码就不是很重要啦,先不管,我们直接去看syncScrollTop这个方法

    // When data size reduce. It may trigger native scroll event back to fit scroll position
  function onFallbackScroll(e: React.UIEvent<HTMLDivElement>) {
    const { scrollTop: newScrollTop } = e.currentTarget;
    if (newScrollTop !== offsetTop) {
      syncScrollTop(newScrollTop);
    }

    // Trigger origin onScroll
    onScroll?.(e);
    triggerScroll();
  }

这个方法其实是个hooks,从外面拿到参数后,根据是方法还是值来给value赋值,我们可以暂时把外面传进来的值等价为value,最后调用keepInRange对value进行处理

  const [offsetTop, setOffsetTop] = useState(0);

  function syncScrollTop(newTop: number | ((prev: number) => number)) {
    setOffsetTop((origin) => {
      let value: number;
      if (typeof newTop === 'function') {
        value = newTop(origin);
      } else {
        value = newTop;
      }

      const alignedTop = keepInRange(value);

      return alignedTop;
    });
  }

我们看看keepInRange做了什么事情,可以看到它其实就是实时获取了最大的可滚动高度,并将其和传进来的newTop进行比较,防止newTop超出可滚动的高度

  function keepInRange(newScrollTop: number) {
    let newTop = newScrollTop;
    if (!Number.isNaN(maxScrollHeightRef.current)) {
      newTop = Math.min(newTop, maxScrollHeightRef.current);
    }
    newTop = Math.max(newTop, 0);
    return newTop;
  }

所以总结而言就是原生dom滚动后的scorllTop会被传给setOffsetTop来更新offsetTop,那么我们去看看offsetTop的变化会影响什么就知道了

接下来我这个代码有点长,大家要忍一下╰_╯:

const { scrollHeight, start, end, offset: fillerOffset } = React.useMemo(() => {
    // 这里省略了一些无关紧要的代码
    let itemTop = 0;
    let startIndex: number;
    let startOffset: number;
    let endIndex: number;

    const dataLen = mergedData.length;
    for (let i = 0; i < dataLen; i += 1) {
      const item = mergedData[i];
      const key = getKey(item);

      const cacheHeight = heights.get(key);
      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

      // Check item top in the range
      if (currentItemBottom >= offsetTop && startIndex === undefined) {
        startIndex = i;
        startOffset = itemTop;
      }

      // Check item bottom in the range. We will render additional one item for motion usage
      if (currentItemBottom > offsetTop + height && endIndex === undefined) {
        endIndex = i;
      }

      itemTop = currentItemBottom;
    }

    // When scrollTop at the end but data cut to small count will reach this
    if (startIndex === undefined) {
      startIndex = 0;
      startOffset = 0;

      endIndex = Math.ceil(height / itemHeight);
    }
    if (endIndex === undefined) {
      endIndex = mergedData.length - 1;
    }
    // Give cache to improve scroll experience
    endIndex = Math.min(endIndex + 1, mergedData.length - 1);

    return {
      scrollHeight: itemTop,
      start: startIndex,
      end: endIndex,
      offset: startOffset,
    };
  }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, scrollToCacheState]);

总的来看这个useMemo依赖于offsetTop的变化计算出scrollHeight, start, end, offset,这个start和end影响了将要加载的起始节点,scrollHeight则影响了可滚动的整体高度,offset则是我们上面提到的rc-virtual-list-holder-inner使用css transform对它进行垂直方向的偏移量

现在我们来对它进行拆解分析

    let itemTop = 0;
    let startIndex: number;
    let startOffset: number;
    let endIndex: number;

    const dataLen = mergedData.length;
    for (let i = 0; i < dataLen; i += 1) {
      const item = mergedData[i];
      const key = getKey(item);

      const cacheHeight = heights.get(key);
      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
      // 省略了一些内容
      itemTop = currentItemBottom;
    }

首先是一个for循环,根据用户传入的数据长度,对每项数据进行遍历,获取对应节点的缓存高度cacheHeight(一个节点如果从来没有真实渲染过是不会有缓存高度的),然后计算当前项的底部为此项距离顶部的高度(itemTop)加上自身的高度(有缓存取缓存,没有就从用户设定的itemHeight取),最后将这一项的底部赋值给itemTop作为下一项的顶部itemTop = currentItemBottom:

const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

简单来说整个高度的计算就是对每一项的高度进行累加出来了,现在我们再把上面省略的内容返回来

      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

      // Check item top in the range
      if (currentItemBottom >= offsetTop && startIndex === undefined) {
        startIndex = i;
        startOffset = itemTop;
      }

      // Check item bottom in the range. We will render additional one item for motion usage
      if (currentItemBottom > offsetTop + height && endIndex === undefined) {
        endIndex = i;
      }

      itemTop = currentItemBottom;

这里的计算就是获取新offsetTop下的起始节点和终止节点,它根据判断当前项的底部是否等于或超出新offsetTop来确实当前项是否是起始点,而终止节点它是根据当前新offsetTop+可视区范围的Height的和值来计算的

最后还有一些细节要处理,大体就是最后都没有算出来结果就视情况把起始点或终点放在滚动区域的首尾位置

    // When scrollTop at the end but data cut to small count will reach this
    if (startIndex === undefined) {
      startIndex = 0;
      startOffset = 0;

      endIndex = Math.ceil(height / itemHeight);
    }
    if (endIndex === undefined) {
      endIndex = mergedData.length - 1;
    }
    // Give cache to improve scroll experience
    endIndex = Math.min(endIndex + 1, mergedData.length - 1);

最后计算出来的start、end会传给useChildren方法来渲染节点,而其偏移量也会被设置上去

  const listChildren = useChildren(
    mergedData,
    start,
    end,
    scrollWidth,
    setInstanceRef,
    children,
    sharedConfig,
    scrollToCacheState,
  );

方法scrollTo实现原理

看了以上内容同志们对于虚拟列表的实现应该就有了个大概的认识,但是这里还有一个挺有意思的东西,我们都知道antd的虚拟化列表实例上提供了scrollTo方法,用户可以用来滚动到指定节点,这个方法是怎么实现的呢

  React.useImperativeHandle(ref, () => ({
    getScrollInfo: getVirtualScrollInfo,
    scrollTo: (config) => {
      function isPosScroll(arg: any): arg is ScrollPos {
        return arg && typeof arg === 'object' && ('left' in arg || 'top' in arg);
      }

      if (isPosScroll(config)) {
        // Scroll X
        if (config.left !== undefined) {
          setOffsetLeft(keepInHorizontalRange(config.left));
        }

        // Scroll Y
        scrollTo(config.top);
      } else {
        scrollTo(config);
      }
    },
  }));

这个方法是通过React.useImperativeHandle暴露出去的,其主要是调用了scrollTo(config),那我们去看看scrollTo这个方法,这个方法是通过 hooks useScrollTo返回出来的,代码还是有点长,忍一下╰_╯:

export default function useScrollTo<T>(
  containerRef: React.RefObject<HTMLDivElement>,
  data: T[],
  heights: CacheMap,
  itemHeight: number,
  getKey: GetKey<T>,
  collectHeight: () => void,
  syncScrollTop: (newTop: number) => void,
  triggerFlash: () => void,
): (arg: number | ScrollTarget) => void {
  const scrollRef = React.useRef<number>();

  return (arg) => {
    // 此处有省略
    if (typeof arg === 'number') {
      syncScrollTop(arg);
    } else if (arg && typeof arg === 'object') {
      let index: number;
      const { align } = arg;

      if ('index' in arg) {
        ({ index } = arg);
      } else {
        index = data.findIndex((item) => getKey(item) === arg.key);
      }
      const { offset = 0 } = arg;

      // We will retry 3 times in case dynamic height shaking
      const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => {
        if (times < 0 || !containerRef.current) return;

        const height = containerRef.current.clientHeight;
        let needCollectHeight = false;
        let newTargetAlign: 'top' | 'bottom' | null = targetAlign;

        // Go to next frame if height not exist
        if (height) {
          const mergedAlign = targetAlign || align;

          // Get top & bottom
          let stackTop = 0;
          let itemTop = 0;
          let itemBottom = 0;

          const maxLen = Math.min(data.length, index);

          for (let i = 0; i <= maxLen; i += 1) {
            const key = getKey(data[i]);
            itemTop = stackTop;
            const cacheHeight = heights.get(key);
            itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

            stackTop = itemBottom;

            if (i === index && cacheHeight === undefined) {
              needCollectHeight = true;
            }
          }

          // Scroll to
          let targetTop: number | null = null;

          switch (mergedAlign) {
            case 'top':
              targetTop = itemTop - offset;
              break;
            case 'bottom':
              targetTop = itemBottom - height + offset;
              break;

            default: {
              const { scrollTop } = containerRef.current;
              const scrollBottom = scrollTop + height;
              if (itemTop < scrollTop) {
                newTargetAlign = 'top';
              } else if (itemBottom > scrollBottom) {
                newTargetAlign = 'bottom';
              }
            }
          }

          if (targetTop !== null && targetTop !== containerRef.current.scrollTop) {
            syncScrollTop(targetTop);
          }
        }

        // We will retry since element may not sync height as it described
        scrollRef.current = raf(() => {
          if (needCollectHeight) {
            collectHeight();
          }
          syncScroll(times - 1, newTargetAlign);
        }, 2); // Delay 2 to wait for List collect heights
      };

      syncScroll(3);
    }
  };
}

仍然是对它进行拆解分析,首先它从用户传进来的参数中提取要滚动到的距离或序号,如果是数字直接调用syncScrollTop,也就是上面提过的setOffesetTop重新触发计算始末节点和偏移量

如果是对象的话,就要从其中提取出index出来,直接获取或查找

    if (typeof arg === 'number') {
      syncScrollTop(arg);
    } else if (arg && typeof arg === 'object') {
       if ('index' in arg) {
        ({ index } = arg);
      } else {
        index = data.findIndex((item) => getKey(item) === arg.key);
      }
      // 下面省略了很多内容...
    }

获取到index以后就要计算它的偏移高度了,它的计算逻辑在syncScroll方法中,但是它进行了三次的递归调用,这是为什么呢?

我们前面提到过缓存高度,面对同一个节点,有缓存高度的和没有的最后在计算整体高度时取的这个节点的高度是不同的,那么当用户调用这个方法滚动到一个没有加载过的远程节点时,实际的节点高度和设定的高度不一样,这个方法在计算偏移量时就势必会出错,最后导致滚动到错误的位置,antd的解决方式是滚动三次,第一次加载出对应的节点并拿到缓存的高度,剩下两次再进行确认缓存都已经拿到,这个过程会触发三次的setOffesetTop

它在每一次计算完成后会先延迟一定时间raf(() => {...}, 2),这个raf是基于window.requestAnimationFrame实现的(如果没有window对象就直接延时16ms啦),而requestAnimationFrame 接受一个回调函数作为参数,该函数会在每一帧之前被执行,也就是说这个放在这个函数里的回调会在浏览器重新绘制后被触发

等节点真实加载完毕后,再调用collectHeight(),来获取缓存高度,然后再调用下一次的滚动

    // We will retry 3 times in case dynamic height shaking
    const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { 
        // 省略....
        // We will retry since element may not sync height as it described
        scrollRef.current = raf(() => {
          if (needCollectHeight) {
            collectHeight();
          }
          syncScroll(times - 1, newTargetAlign);
        }, 2); // Delay 2 to wait for List collect heights
    };
    syncScroll(3);

这里还传了个newTargetAlign,是用来判断滚动到对应节点时,是将节点放在可视区域的顶部还是底部的,它一方面依赖于用户的设置,一方面也会根据实际情况发生变化,它会影响最后计算出来的offsetTop,相关代码如下:

const { align } = arg;
const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => {
    const mergedAlign = targetAlign || align;
    // Scroll to
    let targetTop: number | null = null;
    // 此处有省略......... 
    switch (mergedAlign) {
      case 'top':
        targetTop = itemTop - offset;
        break;
      case 'bottom':
        targetTop = itemBottom - height + offset;
        break;
      default: {
        const { scrollTop } = containerRef.current;
        const scrollBottom = scrollTop + height;
        if (itemTop < scrollTop) {
          newTargetAlign = 'top';
        } else if (itemBottom > scrollBottom) {
          newTargetAlign = 'bottom';
        }
      }
    }
}

而其最主要的偏移高度的计算其实还是通过for循环算出来的,这里不再赘述:

// Get top & bottom
let stackTop = 0;
let itemTop = 0;
let itemBottom = 0;
const maxLen = Math.min(data.length, index);
for (let i = 0; i <= maxLen; i += 1) {
  const key = getKey(data[i]);
  itemTop = stackTop;
  const cacheHeight = heights.get(key);
  itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
  stackTop = itemBottom;
  if (i === index && cacheHeight === undefined) {
    needCollectHeight = true;
  }
}

总的来说以上就是虚拟化列表的实现原理,源码剖析起来也是十分简单的,同志们共勉^_^