react虚拟列表组件库rc-virtual-list源码阅读

6,663 阅读5分钟

rc-virtual-list 仓库地址

github.com/react-compo…

关于 rc-virtual-list

rc-virtual-list是一个react虚拟列表组件库。虚拟列表是目前比较常见的长列表性能优化手段,常用于直播场景下的评论等。当列表元素过多时,全部渲染到页面中,无疑会引起卡顿等性能问题。虚拟列表是只将列表容器内处于视野中的内容进行渲染的性能优化手段。

以官网demo为例

image.png 这是一个列表长度为1000的虚拟列表真实渲染情况,从图中可以看出,只渲染了当前页面可视区内的5个元素。

源码阅读

先看一下demo的接入示例

import List from 'rc-virtual-list';

<List
  data={data}
  data-id="list"
  height={200}
  itemHeight={20}
  itemKey="id"
  ref={listRef}
  style={{
    border: '1px solid red',
    boxSizing: 'border-box',
  }}
>
  {(item, index) => (
    <ForwardMyItem
      {...item}
      motionAppear={animating && insertIndex === index}
      visible={!closeMap[item.id]}
      onClose={onClose}
      onLeave={onLeave}
      onAppear={onAppear}
      onInsertBefore={onInsertBefore}
      onInsertAfter={onInsertAfter}
    />
  )}
</List>

从示例可以看出,核心是List组件,那么让我们从仓库里找到这个组件。很好定位,就在src目录下。没有src目录的源码仓库可以通过package.json的main入口获取到仓库的入口文件。

image.png

在介绍List的代码之前,先简单介绍一下实现虚拟列表的原理。

回归到上面demo渲染的dom

image.png 可以看到最终渲染的元素,有下面几个容器组成:

  • 列表容器:rc-virtual-list
  • 列表内容容器:rc-virtual-list-holder
    • 要点:
      • 固定高度
      • 超出部分隐藏,最终也是通过控制该容器的滚动高度来达到元素滚动的目的
  • div:高度为所有列表内容都渲染出来的高度,这里是为了撑开父元素,实现父元素的滚动
  • 渲染列表容器:rc-virtual-list-holder-inner
  • 单个列表内容:item

滚动的实现

核心是通过监听wheel事件(pc端部分浏览器)进行自定义滚动,监听wheel事件可以获取到滚轮的滑动距离,并将滑动距离总和认为是容器rc-virtual-list-holder应该滚动的高度

下面看一下滚动的具体实现

export default function useFrameWheel(
  inVirtual: boolean,
  isScrollAtTop: boolean,
  isScrollAtBottom: boolean,
  onWheelDelta: (offset: number) => void,
): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] {

  function onWheel(event: WheelEvent) {
    if (!inVirtual) return;
    raf.cancel(nextFrameRef.current);

    const { deltaY } = event;
    // 获取到滚轮滑动距离,并认为该距离是元素滚动高度
    offsetRef.current += deltaY;
    wheelValueRef.current = deltaY;

    // Do nothing when scroll at the edge, Skip check when is in scroll
    if (originScroll(deltaY)) return;

    // Proxy of scroll events
    if (!isFF) {
      event.preventDefault();
    }

    nextFrameRef.current = raf(() => {
      // Patch a multiple for Firefox to fix wheel number too small
      // ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266
      // 火狐浏览器下滚动距离*10,其他浏览器下不变
      const patchMultiple = isMouseScrollRef.current ? 10 : 1;
      onWheelDelta(offsetRef.current * patchMultiple);
      offsetRef.current = 0;
    });
  }

  // A patch for firefox
  function onFireFoxScroll(event: FireFoxDOMMouseScrollEvent) {
  }

  return [onWheel, onFireFoxScroll];
}
  
  const [onRawWheel, onFireFoxScroll] = useFrameWheel(
    useVirtual,
    isScrollAtTop,
    isScrollAtBottom,
    (offsetY) => {
      // 通过syncScrollTop实现元素内容的滚动
      syncScrollTop((top) => {
        const newTop = top + offsetY;
        return newTop;
      });
    },
  );
  useLayoutEffect(() => {
    // 这里目前只看一下 pc 端的
    componentRef.current.addEventListener('wheel', onRawWheel);
    return () => {
      componentRef.current.removeEventListener('wheel', onRawWheel);
    };
  }, [useVirtual]);

通过syncScrollTop方法设置容器rc-virtual-list-holder的滚动高度

  function syncScrollTop(newTop: number | ((prev: number) => number)) {
    setScrollTop((origin) => {
      let value: number;
      if (typeof newTop === 'function') {
        value = newTop(origin);
        console.log(origin, value)

      } else {
        value = newTop;
      }
        
      // 判断滚动距离是否在合法范围
      const alignedTop = keepInRange(value);
      // 设置容器 rc-virtual-list-holder 的滚动高度
      componentRef.current.scrollTop = alignedTop;
      return alignedTop;
    });
  }

如何计算应该显示的元素范围

当以下几个变量改变时,会触发显示元素的重新计算

  • inVritual: 虚拟列表开启且满足使用虚拟列表的条件
  • useVirtual: 是否虚拟列表
  • scrollTop: 列表内容滚动高度
  • mergedData: 列表数据
  • heightUpdatedMark: 元素内容高度是否变化
  • height: 可视区域的高度
  const { scrollHeight, start, end, offset } = React.useMemo(() => {
    if (!useVirtual) {
      return {
        scrollHeight: undefined,
        start: 0,
        end: mergedData.length - 1,
        offset: undefined,
      };
    }

    // Always use virtual scroll bar in avoid shaking
    if (!inVirtual) {
      return {
        scrollHeight: fillerInnerRef.current?.offsetHeight || 0,
        start: 0,
        end: mergedData.length - 1,
        offset: undefined,
      };
    }

    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);
      // heights的获取是通过useHeights的hook获取的,下面再讲,比较巧妙
      const cacheHeight = heights.get(key);
      // 注意这里,cacheHeight是用来存放各个item的实际高度,如果存在,使用这个高度,如果不存在,使用传进来的item的高度
      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
 
      // Check item top in the range
      if (currentItemBottom >= scrollTop && startIndex === undefined) {
        // 第i个元素(含)之前所有元素的高度超过了滚动高度,则起始元素索引设为i
        startIndex = i;
        startOffset = itemTop;
      }

      // Check item bottom in the range. We will render additional one item for motion usage
      if (currentItemBottom > scrollTop + height && endIndex === undefined) {
        // 第i个元素(含)之前所有元素的高度 超过了 滚动高度+可视区域的高度,结束索引设为i
        endIndex = i;
      }
      
      itemTop = currentItemBottom;
    }

    // Fallback to normal if not match. This code should never reach
    /* istanbul ignore next */
    if (startIndex === undefined) {
      startIndex = 0;
      startOffset = 0;
    }
    if (endIndex === undefined) {
      endIndex = mergedData.length - 1;
    }

    // Give cache to improve scroll experience
    endIndex = Math.min(endIndex + 1, mergedData.length);
    return {
      scrollHeight: itemTop,
      start: startIndex,
      end: endIndex,
      offset: startOffset,
    };
  }, [inVirtual, useVirtual, scrollTop, mergedData, heightUpdatedMark, height]);

如何感知列表元素高度的变化及存储元素列表的高度

元素内容通过下面这个组件进行渲染,而这个组件又调用了自定义hooks useChidren

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

下面看一下useChildren做了哪些事情,从下面的代码可以看出,useChildren主要是进行list列表的渲染,而在渲染列表时,又用Item组件进行了一层包裹

export default function useChildren<T>(
  list: T[],
  startIndex: number,
  endIndex: number,
  setNodeRef: (item: T, element: HTMLElement) => void,
  renderFunc: RenderFunc<T>,
  { getKey }: SharedConfig<T>,
) {
  return list.slice(startIndex, endIndex + 1).map((item, index) => {
    const eleIndex = startIndex + index;
    const node = renderFunc(item, eleIndex, {
      // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
    }) as React.ReactElement;

    const key = getKey(item);
    return (
      <Item key={key} setRef={ele => setNodeRef(item, ele)}>
        {node}
      </Item>
    );
  });
}

Item组件包裹了外部传入的列表元素的JSXElement

export function Item({ children, setRef }: ItemProps) {
  const refFunc = React.useCallback(node => {
    setRef(node);
  }, []);

  return React.cloneElement(children, {
    ref: refFunc,
  });
}

经过这么一层包装,当通过ref获取子节点时,将会调用refFunc -> setRef -> setInstanceRef这也是为什么当元素高度可变时需要用React.forwardRef进行列表元素的包裹

下面看一下 setInstanceRef的实现

function setInstanceRef(item: T, instance: HTMLElement) {
    const key = getKey(item);
    const origin = instanceRef.current.get(key);

    if (instance) {
      instanceRef.current.set(key, instance);
      collectHeight();
    } else {
      instanceRef.current.delete(key);
    }

    // Instance changed
    if (!origin !== !instance) {
      if (instance) {
        onItemAdd?.(item);
      } else {
        onItemRemove?.(item);
      }
    }
  }
 
 
  function collectHeight() {
    heightUpdateIdRef.current += 1;
    const currentId = heightUpdateIdRef.current;

    Promise.resolve().then(() => {
      // Only collect when it's latest call
      if (currentId !== heightUpdateIdRef.current) return;

      instanceRef.current.forEach((element, key) => {
        if (element && element.offsetParent) {
          const htmlElement = findDOMNode<HTMLElement>(element);
          const { offsetHeight } = htmlElement;
          if (heightsRef.current.get(key) !== offsetHeight) {
              // 将当前元素的高度进行存储
            heightsRef.current.set(key, htmlElement.offsetHeight);
          }
        }
      });

      // Always trigger update mark to tell parent that should re-calculate heights when resized
      setUpdatedMark(c => c + 1);
    });
  }

结语

想要了解虚拟列表的具体实现,主要应该关注以下几点,其中前两点是核心,后两点是代码健壮性的保证:

  1. 如何实现滚动
  2. 如何通过滚动高度反向计算显示哪几个元素
  3. 边界处理
  4. 兼容性

边界处理和兼容性部分我都在本篇文章中略去了,但是其实还是比较重要的,因为我这里是主要想了解下前两点的实现。感兴趣的同学可以下载源码学习。