虚拟列表的那些事儿

1,029 阅读4分钟

前言

在工作中,我们可能会遇到渲染大数据列表的场景。大家首先想到的是使用网上现成的虚拟列表插件,但是,产品的骚操作需求何其多,网上的虚拟列表插件有时也只能徒呼奈何!。本文先介绍了普通虚拟列表(组件内部滚动)的实现方式,然后在此基础上阐述了监听组件外部元素的滚动实现虚拟列表的思路。

原理

渲染部分数据,滚动时去实时计算哪些数据需要被渲染。

问题

1.既然只是渲染部分数据,那么我们怎么把滚动条撑开?

2.渲染的数据该如何布局?

3.在什么时间点去计算哪些数据需要被渲染?

生成一个简单的列表

首先我们需要有一个div,它是我们的容器元素,我们为它设置一个固定高度height,这个高度由组件外部传入,当内容高度高于height时,容器需要滚动,所以还需要为它设置一些css样式,overflow:auto.

<div
  className="virtualList"
  style={{
    height: `${height}px`,
  }}
  onScroll={onListScroll}
  ref={scrollRef as LegacyRef<HTMLDivElement>}
>
</div>

// css部分
.virtualList{
  position: relative;
  width: 100%;
  overflow: auto;
}
 

当我们有了这样一个容器之后,我们还需要为其添加一些滚动事件,onScroll={onListScroll},

const onListScroll=(e: UIEvent<HTMLDivElement>) => {
  const target = e.target as any;
  const { scrollTop } = target;
  
  console.log(scrollTop);
}

这样我们可以拿到滚动内容的高度,到目前为止,我们没有对滚动事件做任何处理。

接下来,我们继续向容器元素添加一些东西

<div
  className="virtualList"
  style={{
    height: `${height}px`,
  }}
  onScroll={onListScroll}
  ref={scrollRef as LegacyRef<HTMLDivElement>}
>
    <div
      className="listContent"
      ref={listContentRef as LegacyRef<HTMLDivElement>}
    >
      {sourceData.map((item, index) => {
        return (
          <div
            className="virtualListRow"
            style={{
              height: `${itemHeight}px`,
            }}
            key={item.key}
          >
            {renderItem(item, index)}
          </div>
        );
      })}
    </div>
</div>

上面这部分代码我们渲染了一个列表,很常规的操作,将所有数据都渲染出来,itemHeight由外部传入,表示每一行的高度,renderItem也由外部传入,用户想要把每一行渲染成什么样子,由renderItem决定,到了这一步,我们仿佛做了什么,又好像什么都没有做😂(因为最主要的部分都交给了用户)。

虚拟列表

1.撑开滚动高度

image.png 我们知道虚拟列表是只渲染部分数据,那么怎么把滚动条撑开,使它看起来和渲染全部数据时一样高呢? 这个时候我们需要有一个元素来撑开高度

<div
  className="virtualList"
  style={{
    height: `${height}px`,
  }}
  onScroll={onListScroll}
  ref={scrollRef as LegacyRef<HTMLDivElement>}
>
    <div
      className="listContent"
      ref={listContentRef as LegacyRef<HTMLDivElement>}
    >
      {sourceData.map((item, index) => {
        return (
          <div
            className="virtualListRow"
            style={{
              height: `${itemHeight}px`,
            }}
            key={item.key}
          >
            {renderItem(item, index)}
          </div>
        );
      })}
    </div>
    {/* 占位元素 */}
    <div style={{ height: totalHeight }}/>
</div>

totalHeight怎么来?sourceData.length * itemHeight,此时把listContent设置为绝对定位。

.virtualList{
  position: relative;
  width: 100%;
  overflow: auto;
  .listContent{
    position: absolute;
    width: 100%;
    z-index: 9;
  }
  .virtualListRow{
    position: relative;
  }
}

2.渲染部分数据

接下来,我们需要计算哪些数据需要被渲染在页面上,我们先来看看初始化的时候需要渲染什么数据。

const [position, setPosition] = useState<IPosition>({ start: 0, end: 0 });

/** 获取需要渲染到页面上的数据数量 */
const getRenderCount = useCallback(
(data: any[]) => {
  if (height) {
    return Math.ceil(height / itemHeight);
  }
  // 渲染所有
  return sourceData.length;
},
[height, itemHeight, sourceData.length],
);

/** 一般来说页面渲染完成之后,renderCount是固定,为了避免重新计算,将此值保存 */
const renderCountRef = useRef<number>(0);

// 初始化 计算position
useEffect(() => {
const renderCount = getRenderCount(sourceDataRef.current);
renderCountRef.current = renderCount;
setPosition({ start: 0, end: renderCount });
}, [getRenderCount]);

用可是区域高度height除以每一行的高度itemHeight,获得初次加载时需要渲染的数据数量,我们使用position这个状态来记录渲染的初始位置和结束位置。

/** 获取最终渲染到页面的数据 */
const renderData = useMemo(() => {
return sourceData.slice(position.start, position.end);
}, [position, sourceData]);

renderData就是我们要渲染到页面上的数据。那么代码就变成这个样子:

<div
  className="virtualList"
  style={{
    height: `${height}px`,
  }}
  onScroll={onListScroll}
  ref={scrollRef as LegacyRef<HTMLDivElement>}
>
    <div
      className="listContent"
      ref={listContentRef as LegacyRef<HTMLDivElement>}
    >
      {renderData.map((item, index) => {
        return (
          <div
            className="virtualListRow"
            style={{
              height: `${itemHeight}px`,
            }}
            key={item.key}
          >
            {renderItem(item, position.start + index)}
          </div>
        );
      })}
    </div>
    {/* 占位元素 */}
    <div style={{ height: totalHeight }}/>
</div>

注意:renderItem(item, index)变成了,renderItem(item, position.start + index),因为我们要传给renderItem始终是这个行数据在sourceData中的索引,而不是在renderData中索引。

3.处理滚动逻辑

上面的代码实现了渲染部分数据,但是滚动条条滚动之后,数据滚出可视区域就没有了,所以,接下来我们需要处理滚动的逻辑

const onListScroll=(e: UIEvent<HTMLDivElement>) => {
  const target = e.target as any;
  const { scrollTop } = target;
  
  /** 被遮住的列表项数量 */
  const overflowNum = computedOverflowCount(sourceData, scrollTop, itemHeight);
  /** 当滚到下一个列表项时,重新设置需要渲染的起始、结束列表项位置 */
  if (overflowNum !== position.start && listContentRef.current) {
    const diff = overflowNum;
    // 在偏移数量以内不需要设置transform,在偏移数量以外需要设置
    if (diff > 0) {
      setPosition({
        start: diff,
        end: diff + renderCountRef.current,
      });
      // 通过transform属性,把位置重置为最初位置,造成一直往上滚动的错觉
      listContentRef.current.style.transform = `translate3d(0,${computedTranslate(
        sourceData,
        diff,
        itemHeight,
      )}px,0)`;
    } else {
      setPosition({
        start: 0,
        end: overflowNum + renderCountRef.current,
      });
      listContentRef.current.style.transform = `translate3d(0,0,0)`;
    }
  }
}

首先我们需要计算滚动高度scrollTop时,有多少行被覆盖了.

/**
 * 计算被遮住的行数(不满一个的,不计算入内)
 * @param overflowHeight 遮住区域高度
 * @param itemHeight 
 */
const computedOverflowCount = (
  overflowHeight: number,
  itemHeight: IItemHeight,
) => {
  return Math.floor(overflowHeight / itemHeight);
};

当滚到下一行时,重新设置需要渲染的起始、结束行位置,被遮住几行,起始位置就是几,结束位置为遮住的行数+可视区域能够渲染的行数.

  setPosition({
    start: diff,
    end: diff + renderCountRef.current,
  });
  // 通过transform属性,把位置重置为最初位置,造成一直往上滚动的错觉
  listContentRef.current.style.transform = `translate3d(0,${computedTranslate(
    sourceData,
    diff,
    itemHeight,
  )}px,0)`;

为了让我们渲染的数据始终处于可视区域,我们需要通过transform属性来将listContent元素往下挪,遮住的行数 * 行高.

/**
 * 计算Translate移动的距离
 * @param count 计算几个
 * @param itemHeight 行高
 * @returns
 */
const computedTranslate = (
  count: number,
  itemHeight: IItemHeight,
) => {
  return count * itemHeight;
};

这样,我们就完成了一个最基础的虚拟列表。

动态行高

上面的行高itemHeight是一个固定值,但是很多时候每行的行高可能是不固定的,这个时候我们允许itemHeight是一个函数,去动态计算行高

itemHeight:number | ((rowData: any) => number);

所有涉及到行高的地方都需要判断是number类型还是function,如:

/**
 * 计算每行的行高
 * @param rowData 行数据
 * @param itemHeight 行高
 * @returns
 */
const computedRowHeight = (rowData: any, itemHeight: IItemHeight) => {
  if (typeof itemHeight === 'function') {
    return itemHeight(rowData);
  }
  return itemHeight;
};

/**
 * 计算被遮住的行数(不满一个的,不计算入内)
 * @param sourceData 源数据
 * @param overflowHeight 遮住区域高度
 * @param itemHeight 行高
 */
const computedOverflowCount = (
  sourceData: any[],
  overflowHeight: number,
  itemHeight: IItemHeight,
) => {
  let count = 0;
  let height = 0;
  if (typeof itemHeight === 'function') {
    sourceData.every((item) => {
      if (height <= overflowHeight) {
        height += itemHeight(item);
        count += 1;
        return true;
      }
      return false;
    });
    return count;
  }
  return Math.floor(overflowHeight / itemHeight);
};

预渲染

Video_22-04-25_11-58-52.gif

当滚动比较快的时候,会出现短暂空白,为了优化这个问题,我们可以在可视区域外预渲染部分数据,。

interface IVirtualList {
  ...省略...
  /** 数量偏移量 */
  offsetCount?: {
    before: number;
    after: number;
  };
}

...省略...
// 初始化 计算position,需要多渲染部分数据,即offsetCount.after设置的数据行数
  useEffect(() => {
    const renderCount = getRenderCount(sourceDataRef.current);
    renderCountRef.current = renderCount;
    setPosition({ start: 0, end: renderCount + offsetCount.after });
  }, [getRenderCount, offsetCount.after]);

const onListScroll=(e: UIEvent<HTMLDivElement>) => {
  ...省略...
  if (overflowNum !== position.start && listContentRef.current) {
    // 需要减去头部预渲染的部分,因为预渲染的行不用销毁,所以设置position的时候要算上预渲染的数量
    const diff = overflowNum - offsetCount.before;
    if (diff > 0) {
      setPosition({
            start: diff,
            end: overflowNum + renderCountRef.current + offsetCount.after,
          });
      // 预渲染的那几个被滚上去之后,listContent不用挪位置
      listContentRef.current.style.transform = `translate3d(0,${computedTranslate(
        sourceData,
        offsetCount.before,
        diff,
        itemHeight,
      )}px,0)`;
    } else {
      ...省略...
    }
  }
}

外部滚动

目前市面上的虚拟列表组件都是组件内部有滚动条,通过监听内部滚动条的变化来实现虚拟列表。

现在有一个需求是组件外部某个元素有滚动条,滚动外部滚动条时,列表能够呈现虚拟效果。

实现原理:不管是组件内部滚动还是组件外部滚动,实现原理都是一样的,只不过组件外部滚动时,需要使用者将外部滚动的元素传到组件内部,以便组件对元素进行滚动事件的监听。

//在interface加入scrollTarget字段
interface IVirtualList {
  ...省略...
  /** 高度 如果是组件内部滚动该值必填 */
  height?: number;
  /** 外部滚动条对象id或者ref对象(先只实现id这种情况) */
  scrollTarget?: string;
  ...省略...
}

/** 在getRenderCount函数中需要做一些处理,获取到滚动的可视区域能够渲染的数据条数 */
  const getRenderCount = useCallback(
    (data: any[]) => {
      // 组件外部滚动
      if (scrollTarget) {
        const scrollDom = document.getElementById(scrollTarget);
        const scrollDomHeight = scrollDom?.offsetHeight!;
        // 如果itemHeight为function,需要动态计算可渲染高度
        if (typeof itemHeight === 'function') {
          return computedRenderCount(data, scrollDomHeight, itemHeight);
        }

        return Math.ceil(scrollDomHeight / itemHeight);
      }
      // 组件内部滚动
      if (typeof height === 'number') {
        ...省略...
      }
      // 渲染所有
      return sourceData.length;
    },
    [height, itemHeight, sourceData.length, scrollTarget],
  );

为外部滚动添加监听事件


/** 绑定监听事件 */
  useEffect(() => {
    let scrollDom: HTMLElement | null = null;
    const listenerHandler = () => onOuterScroll(scrollDom);
    if (scrollTarget) {
      /** 获取滚动元素对象 */
      scrollDom = document.getElementById(scrollTarget);
      scrollDom?.addEventListener('scroll', listenerHandler);
    }
    return () => {
      if (scrollDom) {
        scrollDom.removeEventListener('scroll', listenerHandler);
      }
    };
  }, [onOuterScroll, scrollTarget]);

onOuterScroll函数的代码如下:

const onOuterScroll=(scrollDom: HTMLElement | null)=>{
    if (scrollDom) {
        // 获取内部容器元素与目标滚动元素的距离
        const distance = getDistance(scrollRef.current!, scrollDom);
        // 初始位置
        const initPosition = { start: 0, end: renderCountRef.current + offsetCount.after };
        if (distance < 0) {
          const absDistance = Math.abs(distance);
          const overflowNum = computedOverflowCount(sourceData, absDistance, itemHeight);
          // 当滚到下一个列表项时,
          if (overflowNum !== position.start && listContentRef.current) {
            const diff = overflowNum - offsetCount.before;
            // 在偏移数量以内不需要设置transform,不需要重新设置需要渲染的起始、结束列表项位置
            if (diff > 0) {
              setPosition({
                start: diff,
                end: overflowNum + initPosition.end,
              });

              // 通过transform属性,把位置重置为最初位置,造成一直往上滚动的错觉
              listContentRef.current.style.transform = `translate3d(0,${computedTranslate(
                sourceData,
                offsetCount.before,
                diff,
                itemHeight,
              )}px,0)`;
            } else {
              ...省略 代码同内部滚动一样...
            }
          }
        }
        // 如果没有回归初始值,需要设置为初始值
        else if (position.start !== initPosition.start || position.end !== initPosition.end) {
          setPosition({ ...initPosition });
          if (listContentRef.current) {
            listContentRef.current.style.transform = `translate3d(0,0,0)`;
          }
        }
      }
}

注意:外部滚动宇内部滚动的区别在于,内部滚动我们通过scrollTop直接计算被遮住的行树overflowNum,但是外部滚动的时候,我们需要获取内部容器元素与目标滚动元素的距离来计算overflowNum,distance可能是正数,也可能是负数。

如图:

  • 内部滚动

企业微信截图_470a160d-10c2-45f7-87ad-2d483f183d40.png

  • 外部滚动,distance为正数的情况

企业微信截图_2ba130b8-843f-4b69-8093-1d68e5045ce9.png

  • 外部滚动,distance为负数的情况

企业微信截图_db85338e-da97-458e-b76b-2b895374a201.png

这样,我们就实现了一个监听外部滚动,实现虚拟列表的功能。

效果如下:

我们可以看到右侧,滚动的时候有一部分元素是不发生变化的.新出现的元素发生变化,其余元素不变,避免不必要的渲染.

Video_22-04-28_19-26-36.gif