rc-virtual-list 源码解读

1,062 阅读4分钟

仓库地址

github.com/react-compo… 具体使用可以参考:这里

目录说明

src/
  ├── index.tsx         // 入口文件
  ├── Filter.tsx        // 滚动内容的wrap,里面展示真正内容的高度。
  ├── List.tsx          // 列表组件核心实现。 包括Filter租金,自定义滚动条组件
  ├── Scrollbar.tsx     // 自定义滚动条组件
  ├── Item.tsx          // 每个元素的clone组件,增加ref属性。
  ├── hooks/            // 自定义 hooks
  ├── utils/            // 工具函数
  ├── styles/           // 样式文件
  └── interface.ts          // TypeScript 类型定义

入口文件: src/index.tsx。

整体实现流程:

在说明整体流程前,先看下整体的dom结构。

image.png

image.png

虚拟列表分为两部分:一部分为滚动区域holder, 一部分为自定义实现的滚动条Scollbar

holder组件: 高度为当前可视区域的高度。注意其overflow属性是hidden

  • outer组件: 为列表区域的真实高度
  • inner组件: 为真正渲染列表的壳子
  • list-item组件: 为每个单个元素的真正组件效果,其在Item.tsx文件中进行clone渲染的。增加ref属性,方便记录每条的高度

Scrollbar组件: 为自定义的滚动条组件。模拟滚动条的滑块(thumb)和轨道(track)

模拟滚动实现

在holder容器组件增加了wheel事件。

 useLayoutEffect(() => {
    // Firefox only
    function onMozMousePixelScroll(e: Event) {
      if (useVirtual) {
        e.preventDefault();
      }
    }

    const componentEle = componentRef.current;
    componentEle.addEventListener('wheel', onRawWheel);
    componentEle.addEventListener('DOMMouseScroll', onFireFoxScroll as any);
    componentEle.addEventListener('MozMousePixelScroll', onMozMousePixelScroll);

    return () => {
      componentEle.removeEventListener('wheel', onRawWheel);
      componentEle.removeEventListener('DOMMouseScroll', onFireFoxScroll as any);
      componentEle.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any);
    };
  }, [useVirtual]);

具体滚动逻辑 在useFrameWheel中定义

function onWheel(event: WheelEvent) {
    if (!inVirtual) return;

    // Wait for 2 frame to clean direction
    raf.cancel(wheelDirectionCleanRef.current);
    wheelDirectionCleanRef.current = raf(() => {
      wheelDirectionRef.current = null;
    }, 2);

    const { deltaX, deltaY, shiftKey } = event;

    let mergedDeltaX = deltaX;
    let mergedDeltaY = deltaY;

    if (
      wheelDirectionRef.current === 'sx' ||
      (!wheelDirectionRef.current && (shiftKey || false) && deltaY && !deltaX)
    ) {
      mergedDeltaX = deltaY;
      mergedDeltaY = 0;

      wheelDirectionRef.current = 'sx';
    }

    const absX = Math.abs(mergedDeltaX);
    const absY = Math.abs(mergedDeltaY);

    if (wheelDirectionRef.current === null) {
      wheelDirectionRef.current = horizontalScroll && absX > absY ? 'x' : 'y';
    }

    if (wheelDirectionRef.current === 'y') {
      onWheelY(event, mergedDeltaY);
    } else {
      onWheelX(event, mergedDeltaX);
    }
  }

通过syncScrollTop方法设置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;
    });
  }

setScrollTop为当前区域滚动top值,当这个值更改时,也会同步修改scrollbar组件滑块的位置

scrollbar实现

return (
    <div
      ref={scrollbarRef}
      className={classNames(scrollbarPrefixCls, {
        [`${scrollbarPrefixCls}-horizontal`]: horizontal,
        [`${scrollbarPrefixCls}-vertical`]: !horizontal,
        [`${scrollbarPrefixCls}-visible`]: visible,
      })}
      style={{ ...containerStyle, ...style }}
      onMouseDown={onContainerMouseDown}
      onMouseMove={delayHidden}
    >
      <div
        ref={thumbRef}
        className={classNames(`${scrollbarPrefixCls}-thumb`, {
          [`${scrollbarPrefixCls}-thumb-moving`]: dragging,
        })}
        style={{ ...thumbStyle, ...propsThumbStyle }}
        onMouseDown={onThumbMouseDown}
      />
    </div>
  );

scrollBar的实现比较简单,分成两层,外层是滑道(track)div,里面包含一个滑块儿(track)。 滑道的postion为absolute,加上top和bottom都为0所以滑道的高度就是父元素高度。

position: absolute;
width: 8px;
top: 0px;
bottom: 0px;
right: 0px;
visibility: hidden;

基于下面的逻辑

滑块滚动的top/(滑道的高度 - 滑块高度)= 内容滚动的top/(内容真实高度 - 内容显示高度) 所以每次内容滚动后,需要动态更改滑块滚动的top值

// ======================== Range =========================
const enableScrollRange = scrollRange - containerSize || 0;
const enableOffsetRange = containerSize - spinSize || 0;

const top = React.useMemo(() => {
    if (scrollOffset === 0 || enableScrollRange === 0) {
      return 0;
    }
    const ptg = scrollOffset / enableScrollRange;
    return ptg * enableOffsetRange;
  }, [scrollOffset, enableScrollRange, enableOffsetRange]);

确定可视区域起始位置、结束位置

看下计算startIndex, endIndex代码

// ========================== Visible Calculation =========================
  const {
    scrollHeight,
    start,
    end,
    offset: fillerOffset,
  } = 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);
      // heigths存放真正每个list-item渲染高度
      const cacheHeight = heights.get(key);
      // 记录当前距离顶部的距离
      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

      // 判断距离顶部的距离是不是大于当前的top值,当大于时则当前的i为起始位置
      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]);

代码中有个heights对象,存放了每个元素的高度。具体存放的逻辑在useHeight.tsx这个钩子函数中。

const heightsRef = useRef(new CacheMap());


function collectHeight(sync = false) {
    cancelRaf();

    const doCollect = () => {
      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);
    };

    if (sync) {
      doCollect();
    } else {
      collectRafRef.current = raf(doCollect);
    }
  }

instanceRef.current中存放了每个真实list-item元素,会遍历这里的元素,记录每个元素的高度。

instanceRef.current存放真实元素主要是下面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);
      }
    }
  }
  
 // Item组件 refFun -> setRef ->setInstanceRef 调用流程
  
 export function Item({ children, setRef }: ItemProps) {
  const refFunc = React.useCallback(node => {
    setRef(node);
  }, []);

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

其中有个raf(doCollect),rag为rc-util/lib/raf提供的方法,是一个用于处理浏览器中的 requestAnimationFrame (简称 raf) 方法的小工具库。在浏览器空闲时才会记录元素的offsetHeight值。