虚拟列表原理及其实现

发布于 2023-07-21 12:43:53 字数 12789 浏览 94 评论 0

虚拟列表(Virtual List)是按需显示的一种技术实现,即只对可视区域中的列表内容进行渲染,区域外的列表数据不渲染或者部分渲染。

长列表数据场景下有些交互不宜使用分页方式加载列表数据,如果渲染全部数据非常消耗性能导致卡顿(主要是 Recalculate Style 和 Layout 阶段的性能消耗)。使用虚拟列表实现按需渲染能极大减少渲染内容,极高的提升渲染性能。

使用姿势

这里 rc-virtual-list 为例,详细使用查看 文档 及其 示例

const MyItem: React.FC<Item> = ({ src, content }, ref) => {
  return (
    <div ref={ref}>
      <img src={src} />
      <p>{content}</p>
    </div>
  );
};
const ForwardMyItem = React.forwardRef(MyItem);

const Demo = () => {
  return (
    <React.StrictMode>
      <div>
        <List
          ref={listRef}
          data={data}
          height={500}
          itemHeight={30} // 用于兜底计算的 Item 高度
          itemKey="id"
          onScroll={onScroll}
        >
          {item => <ForwardMyItem {...item} />}
        </List>
      </div>
    </React.StrictMode>
  );
};

注意点

  1. 如果子项存在动态高度或者高度不统一的情况,需要使用 React.forwardRef 转发 ref 给子 DOM 元素。
  2. 列表项之间不要存在上下间距( margin-topmargin-bottom )。

以上两点如果没有做到,调用组件的 scrollTo(scrollConfig) 方法进行滚动时都会导致滚动位置异常,具体原因可以在后面内容中找到。

实现

这里仅介绍垂直方向的虚拟列表实现,且为了清晰实现思路,示例中源码均做了删减或者改造。

虚拟列表布局

下面是 rc-virtual-list 生成的虚拟列表 DOM 结构:

<div class="rc-virtual-list-holder">
    <div class="scroll-list">
        <div
            class="rc-virtual-list-holder-inner"
           >
            <div class="list-item">2</div>
            <div class="list-item">3</div>
            <div class="list-item">4</div>
            <div class="list-item">5</div>
        </div>
    </div>
</div>

layout

  • 可视区域容器(红色):即 div.rc-virtual-list-holder ,它的作用是限定可视区域,通过 overflow-y: hidden 使得超出可视区域的列表项不可见(灰色区域)。同时它也是滚动容器。

  • 滚动内容(绿色):即 div.scroll-list (我自定义的 className)。它的高度 height = 可视区域内的列表项真实高度总和 + 可视区域外列表项的临时高度总和,即为滚动容器的 scrollHeight

    • 绿色区域内的虚线矩形表示可视区域外的列表项,因为还没有渲染所以高度只是一个临时兜底高度。实线矩形表示当前渲染的列表项,它们的高度是真实高度。
    • 设置临时高度保证了相对准确的内容高度,从而使得可视区容器能够滚动,并且保证滚动条的正确位置。
  • 展示容器(蓝色):即 div.rc-virtual-list-holder-inner ,它的作用是用来装载可视区域内需要真实渲染的列表项。由于真实渲染的列表项(如上图的 2~5)总高度往往不会恰好等于可视区高度,所以展示容器的的高度通常会大于可视区域(蓝色矩形大于红色矩形)。

实现虚拟滚动

保证滚动内容高度大于滚动容器的前提下(即 scrollHeight > clientHeight ),只需要改变滚动容器的 scrollTop 属性即可实现内容的上下滚动。下面是简化后的用于手动触发虚拟滚动的 syncScrollTop 方法:

const [scrollTop, setScrollTop] = useState(0);

function syncScrollTop(newTop: number) {
    setScrollTop(origin => {
      // 改变滚动容器的 scrollTop 属性
      componentRef.current.scrollTop = newTop;
      return newTop;
    });
}

当用户触发滚动事件时(如滑动鼠标滚轮),由于虚拟列表只展示部分 Item,且滚动后才能计算展示内容,快速滚动将出现白屏闪烁,所以需要在可视区域外额外渲染一定数量的 Item 作为滚动缓冲单元,额外渲染的缓冲单元越多,越不容易产生滚动白屏,但过多又失去了按需显示的意义,所以如果直接快速拖拽滚动条缓冲单元也不管用。

rc-virtual-list 的做法是直接阻止用户滚动,通过劫持用户事件并设置 scrollTop 的值来实现滚动。这样做的好处是所有滚动都通过虚拟滚动方法来控制,保证可视区域的 Item 渲染后再控制 scrollTop 跳至滚动位置。因此也不再需要额外渲染缓冲条目。为了避免快速拖拽滚动导致的白屏, rc-virtual-list 把原生的滚动条也干掉了,实现了一个假的滚动条代替,通过 syncScrollTop 实现拖拽滚动逻辑:

<ScrollBar
  ref={scrollBarRef}
  ...
  onScroll={(newScrollTop: number) => syncScrollTop(newScrollTop)}
/>

由于 onScroll 事件是只能在滚动发生后才会触发的 UIEvent ,因而无法在 onScroll 里调用 preventDefault ,所以选择劫持滚动容器上 onScroll 事件的前置事件 onWheel ,在 onWheel 中改变 scrollTop

function onWheel({ deltaY }: WheelEvent) {
  event.preventDefault();
  // 实际代码使用了 requestAnimationFrame 将一帧内的滚动事件合并。
  componentRef.current.scrollTop += deltaY;
}

useLayoutEffect(() => {
    componentRef.current.addEventListener('wheel', onWheel);
    return () => {
      componentRef.current.removeEventListener('wheel', onWheel);
    };
}, [useVirtual]);

计算可视区渲染数据

继续拿出这张图:

layout

只要能计算出 startIndexendIndex 即可得到需要渲染的数据:

const visibleData = data.slice(startIndex, endIndex + 1);

visibleData 渲染后填充到展示容器(蓝色矩形),设置展示容器 transformtranslateY = startOffset 即可保证按需渲染的列表项展示在可视区域内。

const visibleData = data.slice(startIndex, endIndex + 1);
const startOffset = getTotalHeight(0, startIndex - 1);
const listChildren = useChildren(visibleData, renderChildrenFn);

<Component
    className="rc-virtual-list-holder"
    style={componentStyle}
    ref={componentRef}
    >
    <Filler
      height={scrollHeight}
      offset={startOffset}
      ref={fillerInnerRef}
    >
      {listChildren}
    </Filler>
</Component>

假设列表中所有 Item 的高度都已知的情况下(或假设每个 Item 的高度都一致), startOffset 则为 0 ~ startIndex - 1 的高度总和。遍历所有 Item ,当累计高度 >= 滚动容器的 scrollTop 时,当前 index 即为 startIndex 。剩下的 endIndexstartOffsetscrollHeight 都可以轻易的计算出来,下面是简化后的代码:

const { scrollHeight, startIndex, endIndex, startOffset } = useMemo(() => {
  let itemTop = 0;
  let startIndex: number;
  let startOffset: number;
  let endIndex: number;
  
  const dataLen = data.length;
  for (let i = 0; i < dataLen; i += 1) {
    const item = data[i];
    // 假设已经根据 key 缓存了 Item 的高度。
    const key = getKey(item);
    
    const cacheHeight = heights.get(key) ?? itemHeight;
    
    // 当前元素下边框到内容顶部的距离
    const currentItemBottom = itemTop + cacheHeight;

    // 得到 startIndex
    if (currentItemBottom >= scrollTop && startIndex === undefined) {
      startIndex = i;
      startOffset = itemTop;
    }

    // 得到 endIndex,内容需要超出可视区域的下边框
    if (currentItemBottom > scrollTop + height && endIndex === undefined) {
      endIndex = i;
    }
    // 计算出 scrollHeight
    itemTop = currentItemBottom;
  }

  return {
    scrollHeight: itemTop,
    startIndex,
    endIndex,
    startOffset,
  };
}, [scrollTop, data, height]);

如果列表所有 Item 的高度都相等且固定不变时, heights.get(key) = itemHeight ,完成以上就已经基本实现了虚拟列表的功能。

高度不一致

但实际情况是 Item 的高度通常是不一致的,这时候就需要收集 Item 的高度。实际做法是 Item 挂载时根据 key 缓存 Item 的实例,顺便收集 Item 的高度。如果不能获取到 DOM 元素则不能收集到实际高度,只能使用 itemHeight 进行兜底计算,这也是 注意点-1 中需要使用 React.forwardRef 转发 ref 的原因。

const instanceRef = useRef(new Map<React.Key, HTMLElement>());
function setInstanceRef(item: T, instance: HTMLElement) {
  const key = getKey(item);
  const origin = instanceRef.current.get(key);

  if (instance) {
    instanceRef.current.set(key, instance);
    collectHeight(key, instance); // 收集高度
  } else {
    instanceRef.current.delete(key);
  }
}

const listChildren = useChildren(visibleData, renderChildrenFn,setInstanceRef);

每次执行 collectHeight 收集高度时意味着有 Item 挂载,展示容器(蓝色矩形)的高度会发生改变,此时就需要重新计算 startOffset 使得展示容器偏移到正确的位置,渲染数据也要重新计算。 rc-virtual-list 的做法是每次执行 collectHeight 都会更新 heightUpdatedMark += 1heightUpdatedMark 变更则会引发 startOffset 等变量的重新计算。

const [setInstanceRef, collectHeight, heights, heightUpdatedMark] = useHeights(getKey);
const { scrollHeight, startIndex, endIndex, startOffset } = useMemo(() => {
    // ...
    const cacheHeight = heights.get(key) ?? itemHeight;
    // ...
}, [scrollTop, data, height, heightUpdatedMark]);

动态高度

除了高度不一致,还存在动态高度的情况,比如 Item 中存在一个<img /> ​ 元素,图片加载会发起网络请求,此时就不能保证 Item 挂载时图片是加载好了,也就不能保证收集的高度是准确的。

const MyItem: React.FC<Item> = ({ src, content }, ref) => {
  return (
    <div ref={ref}>
      <img src={src} />
      <p>{content}</p>
    </div>
  );
};

其实动态高度与高度不统一的问题可以一起解决,办法是 Item 挂载时缓存实例至 instanceRef ,使用 ResizeObserver 监视展示容器的元素大小变更。

const Filler = React.forwardRef((props: FillerProps, ref: React.Ref<HTMLDivElement>) => {
    // 监视元素大小变更
    const onResize = ({ offsetHeight }) => {
        if (offsetHeight && props.collectHeight) {
            props.collectHeight(); // 收集高度
        }
    }
    // ...
    return (
        <div style={outerStyle}>
            <ResizeObserver onResize={onResize}>
                <div style={innerStyle} className="rc-virtual-list-holder-inner" ref={ref}>
                    {props.children}
                </div>
            </ResizeObserver>
        </div>
    );
});

变更后执行 collectHeight 方法批量收集高度,核心逻辑就是遍历 instanceRef 中已缓存的 Item 实例,收集元素的 offsetHeight 属性作为 Item 的高度并缓存下来。由于 offsetHeight 并不包含 margin ,所以这就是 注意点-2 中存在外边距会导致滚动位置异常的原因。

由于 collectHeight 方法是一个批量收集动作,为避免频繁的重复收集,通过创建微任务对 currentId 进行比对,仅事件循环中最近一次方法调用时收集。

const instanceRef = useRef(new Map<React.Key, HTMLElement>());
const heightsRef = useRef(new CacheMap());
const heightUpdateIdRef = useRef(0);

function collectHeight() {
    heightUpdateIdRef.current += 1;
    const currentId = heightUpdateIdRef.current;
    
    Promise.resolve().then(() => {
        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);
              }
            }
        });
    });
    // ...
}

滚动到指定 Item

rc-virtual-list 提供了一个 scrollTo 方法用来滚动到指定的位置。

type ScrollConfig =
  | { index: number;
      align?: ScrollAlign;
      offset?: number;
    }
  | {
      key: React.Key;
      align?: ScrollAlign;
      offset?: number;
    };
type scrollTo = (arg: number | ScrollConfig) => void

number 即指定的 scrollTop 。当传入 ScrollConfig 时,本质还是找到目标 Item 的 scrollTop 。唯一值得注意的是如果目标 Item 未曾渲染则计算时拿不到缓存高度,只能使用 itemHeight 作为兜底高度进行计算。可以使用 needCollectHeight = true 标记这种情况,滚动后收集高度。而滚动也会重试三次以避免动态高度导致的抖动。省略大量代码:

// useScrollTo.tsx
const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => {
    if (times < 0) return;
    
    // ...
    
    let stackTop = 0;
    let itemTop = 0; // 目标 Item 上边框距离顶部的距离
    let itemBottom = 0; // 目标 Item 下边框距离顶部的距离
    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;
        }
    }
    
    // ... 根据不同的对齐方式计算出 targetTop
    syncScrollTop(targetTop);
    
    // 使用 requestAnimationFrame 合并滚动
    scrollRef.current = raf(() => {
        if (needCollectHeight) {
            collectHeight();
        }
        syncScroll(times - 1, newTargetAlign);
    });
};

// 重试三次
syncScroll(3);

参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

绿萝

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

13886483628

文章 0 评论 0

流年已逝

文章 0 评论 0

℡寂寞咖啡

文章 0 评论 0

笑看君怀她人

文章 0 评论 0

wkeithbarry

文章 0 评论 0

素手挽清风

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文