虚拟列表原理与实现 - 掘金

660 阅读4分钟

本文内容来自于网文,如有侵权,可联系删除

什么是虚拟列表

长列表:是一次性将全部数据以 DOM 的形式渲染到页面上,即使列表项超出了当前屏幕可显示的数量。 缺点:数据量较大时,无疑会占用过多内存,导致各种性能问题,如dom 节点复杂时渲染慢,卡顿等现象。

虚拟列表:只会展示屏幕可视区和屏幕可视区外部上下偏移区的列表项。不增加列表数量,滚动时通过改变这些列表项的内容及位置复用 DOM 元素,达到长列表一样的展示效果。

629459d96d46262053f8c0f0mTKa77HX01.jpeg

如图,对比常规滚动和虚拟滚动列表的构成,常规滚动列表是一次性载入全部列表项,虚拟滚动则一直渲染一定的列表项,从而提升渲染性能。

为什么要使用虚拟列表

为了解决长列表带来的性能问题,在数据量较多时出现的卡顿,渲染慢的等现象,一般会采用分片渲染的方式。 切片渲染:

  1. 分页, 容易打断用户看数据的连续性
  2. 滚动加载 ,滚动加载能保证数据的连续性,切换方式平和,感知小,但是整体的切片数量会不断叠加,性能下降
  3. 虚拟列表, 有些特定情况下不能采用分页或懒加载实现时,如ep 的项目需求看板等页面。

下面我们来看一个普通长列表渲染所需要的时间:

假设我们要点击按钮,往页面上插入10000 条数据,对于简单的dom 节点 ,只渲染一个span:

function List() {
  const [data, setData] = useState([]);

  const onCreateDOMs = () => {
    const array = [];

    const renderSimpleItem = (i) => {
      return `
      <div key=${i} className="item">
        <span>编号:${i}</span>
      </div>`;
    };

    for (var i = 0; i < COUNT; i++) {
      array.push(renderSimpleItem(i));
    }

    setData(array);
  };
  return (
    <div className="container">
      <button onClick={onCreateDOMs}>创建dom元素</button>
      <div dangerouslySetInnerHTML={data.length ? { __html: data.join(' ') } : { __html: '' }} />
    </div>
  );
}

export default List;

通过ChromePerformance工具来详细的分析这段代码从点击按钮到渲染出来的所花费的时间:

1.jpeg

稍微父复杂一点的dom 元素所花费的时间:

const renderItem = (i) => {
      return `
    <div key=${i} className="item">
      <span>编号:${i}</span>
      <p>#${i}  我是一个p标签,我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签</p>
      <p>我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签</p>
      <img src="<https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2Ftp09%2F210F2130512J47-0-lp.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1656403600&t=d8ac70744fac3cd1110ce63714b11ee9>"/>
    </div>`;
    };
复制代码

2.jpeg

Loading :加载的时间

Scripting:执行 js 代码所花费的时间

Rendering: 渲染dom 元素所花费的时间

Painting: dom 元素绘制所需要的时间

System: 操作系统所花费的时间

Idel: 浏览器的空闲时间

由此可见主要的时间是花费在渲染阶段,在仅仅是简单dom 就需要花费 2.s 左右,当dom 节点随着业务变复杂时,所需要花费的时间会更久。为了解决这类问题,可以采用虚拟列表来进行优化。

虚拟列表分为定高和不定高的场景,首先来看看定高的场景:

3.jpeg

滚动容器元素:一般情况下,滚动容器元素是 window 对象。或是某个元素(div)能在内部产生横向或者纵向的滚动的这个元素。

可滚动区域:滚动容器元素的内部内容区域。假设有 100 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 100 * 50。

可视区域:滚动容器元素的视觉可见区域。一般容器元素是 window 对象,可视区域就是浏览器的视口大小;假设容器元素是某个 div ,其高度是 500,那么可视区域就是设置高度为500的区域。

虚拟列表的核心就在于通过计算出startIndex 和endIndex ,只展示视口以内的元素,来提高渲染性能。

高度固定的虚拟列表

定义的dom 结构如下:

scroll-container 为可滚动的区域

list-container 设置其高度 ,position:relative

list-item 列表的每一项,设置position: absolute ,通过设置其向上平移的值 translateY 来模拟真实的位置

<div style={{ height: containerHeight }} className="scroll-container" onScroll={handleScroll} ref={ref}>
      <div style={{ height: totalSize }} className="list-container">
        {list?.map((item) => {
          return (
            <div
              style={{
                transform: `translateY(${item.top}px)`,
              }}
              key={item.item.id}
              className="list-item"
            >
              {renderItem(item.item)}
            </div>
          );
        })}
      </div>
    </div>

在滚动后就需要 通过updateVisibleItems 来计算当前索引位置了

const updateVisibleItems = useCallback(
    (e) => {
      const pool = [];
      const scrollTop = e ? Math.abs(e.target.scrollTop) : 0;
      const scroll = {
        //滚动开始和滚动结束位置
        start: scrollTop,
        end: scrollTop + ref.current.clientHeight, // 滚动距离加上容器高度
      };

      // 避免白屏加入buffer
      scroll.start -= buffer;
      scroll.end += buffer;

      let startIndex = Math.floor(scroll.start / itemHeight); // 开始位置 四舍五入取最小整数
      let endIndex = Math.ceil(scroll.end / itemHeight); // 结束位置 四舍五入取最大整数

      if (startIndex < 0) {
        startIndex = 0;
      }
      if (endIndex > data.length) {
        endIndex = data.length;
      }

      for (let index = startIndex; index < endIndex; index++) {
        let viewItem = {
          item: data[index],
          top: index * itemHeight, // translateY
        };
        pool.push(viewItem);
      }
      setList(pool);
    },
    [buffer, data, itemHeight],
  );

const handleScroll = (e) => {
    updateVisibleItems(e);
};

定高的虚拟列表实现步骤:

  1. 计算 totalSize: 列表的高度固定,所以列表的高度可以根据 列表项的总和 * 列表项高度得出

totalSize= data.length * itemHeight 2. 获取滚动的高度 start e.target.scrollTop end : start + ref.current.clientHeight 3. 计算startIndex 和 endIndex ,得到scroll start ,end 之后,分别除以列表每项的高度即可得到截取所需的开始和结束索引值,这里需要注意对开始索引值向下取整,对结束索引值向上取整。 4. 获取实际渲染list 列表 截取startIndx和endIndex之前的元素,给每项设置top值也就是 translateY 的值 5. 为了滚动时减少白屏的情况,我们会在可视区的上下增加偏移的区域,因此在计算滚动开始和结束位置时要分别减去和加上偏移的距离 buffer

相对于固定高度的列表,高度不固定时的难点是在于无法提前得知列表的高度。 需要父子组件来配合实现,子组件渲染之后,通知父组件来更新实际dom元素的高度,并触发父组件重新渲染数据。

高度不固定的虚拟列表

dom 结构如下

// 父组件
<div
      style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
      className="scroll-container"
      ref={ref}
      onScroll={handleScroll}
    >
      <div style={{ height: totalSize }} className="infinite-list" />
      <div
        className="infinite-list"
        style={{
          transform: `translateY(${startOffset}px)`,
        }}
      >
        {visibleData?.map((item, index) => {
          return (
            <Item
              key={index}
              updateSize={updateSize}
              index={startIndex + index}
              renderItem={() => renderItem(item)}
              item={item.item}
            />
          );
        })}
      </div>
    </div>

// 子组件 Item
class Item extends Component {
  componentDidMount() {
    this.props.updateSize(this.node, this.props.index);
  }

  render() {
    return (
      <div
        ref={(node) => {
          this.node = node;
        }}
      >
        {this.props.renderItem()}
      </div>
    );
  }
}

// 初始化positions useEffect(() => { const initPositions = () => { const arr = data.map((item, index) => { return { index, height: estimatedItemSize, top: index * estimatedItemSize, bottom: (index + 1) * estimatedItemSize, }; }); setPositions(arr); }; initPositions(); }, []);

// 子组件渲染完成之后调用的方法
const updateSize = (node, index) => {
    const rect = node.getBoundingClientRect();
    let height = rect?.height || 0;
    let oldHeight = positions[index]?.height;
    let dValue = oldHeight - height;

    if (dValue) {
      positions[index].bottom = positions[index].bottom - dValue;
      positions[index].height = height;
    }
  };

// 更新索引和偏移量 const handleScroll = (e) => { const scrollTop = e ? Math.abs(e.target.scrollTop) : 0; // 更新开始和结束索引 查找 bottom > scrollTop 的 const start = binarySearch(positions, scrollTop || 0); const end = binarySearch(positions, scrollTop + ref.current.clientHeight || 0);

setStartIndex(start);
setEndIndex(end);
// 更新偏移量
updateStartOffset();

};

const visibleData = useMemo(() => { let start = startIndex - aboveCount; let end = endIndex + belowCount; return data.slice(start, end); }, [startIndex, aboveCount, endIndex, belowCount, data]);


高度不固定时的虚拟列表实现步骤

1.  初始化positions ,根据预估高度初始化列表元素的信息
2.  计算列表的总高度 const totalSize = positions\[positions.length - 1\]?.bottom;
3.  子组件渲染完成之后 调用 updatesize 方法来更新实际的位置信息
4.  计算startIndex 和endIndex : 由于position是有序的可以利用二分查找来找StartIndex和EndIndex
5.  获取实际的List 截取startIndex 和endIndex 之前的数据为list 6.aboveCount ,belowCount 是为解决白屏加入的缓冲区域

> 本文使用 [文章同步助手](https://juejin.cn/post/6940875049587097631) 同步