前端难点总结 -- 虚拟列表

44 阅读5分钟

前端难点总结 -- 虚拟列表

什么是虚拟列表?

做一个虚拟列表首先得知道什么是虚拟列表。 想象如下一个场景,某个页面需要渲染一个列表,列表中有非常多的数据项,可能有数十万的数据项,比如下面我做的这个 demo

1.gif

const ITEM_COUNT = 10000;
const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 500;
const listData = new Array(ITEM_COUNT).fill(0).map((_, i) => i);
const VirtualList = () => {
  return (
    <div className=" h-[calc(100vh-88px)]">
      <div
        className="bg-amber-100 relative overflow-auto"
        style={{ height: CONTAINER_HEIGHT }}
      >
        <div
          className="absolute top-0 left-0 right-0"
          style={{ height: ITEM_COUNT * ITEM_HEIGHT }}
        />

        {listData.map((item) => {
          return (
            <div
              key={item}
              className="p-4 border-b"
              style={{ height: ITEM_HEIGHT }}
            >
              #{item}
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default VirtualList;

不难发现右边滚动条是非常的渺小,这意味这个列表的数据项非常非常多,甚至还可能更多。打开控制台的元素栏可以看到浏览器真的把这些所有的数据项 dom 全都渲染出来了。很明显,这里的资源浪费是比较严重的,为什么?事实上,用户能够看到的列表项相对于全部的列表项而言,只是冰山一角,九牛一毛,但是我们却把所有的列表项的 dom 都直接渲染在浏览器上,可见这里是存在比较大的优化空间的。

既然用户只看得到其中的很小的一部分 dom,我们能不能用一个办法让用户滚到哪儿再渲染对应的这一小部分 dom 呢?有的,兄弟,有的。这个办法有个好高大上的名字 --- 虚拟列表。其原理就是让浏览器只渲染用户能看到的那一部分 dom!

如何实现一个虚拟列表?

其实虚拟列表的实现分为两种,一种是等高的虚拟列表,一种是不等高的。顾名思义,当所有的列表项都是一样的固定高度时,则是等高,都不太一样时,就是不等高。不等高的实现要复杂一些,这里先讲等高如何实现。

首先,我们原先有一个完整的数据 listData,这是刚刚代码里定义的:

const listData = new Array(ITEM_COUNT).fill(0).map((_, i) => i);

显然这个数据太大了,我只要其中看得见的一小部分,于是我定义了一个新的数据:

const [visibleList, _setVisibleList] = useState(listData.slice(0, 15));

再把原来渲染 listData 的代码改成 visibleList 就行了,下面是效果:

2.gif

现在的问题就是划着划着,列表项都没了,而且会发现滑动的过程中,列表项的数字也没有变化。但是正常的列表滑动,里面的列表项都是不断变化的。所以接下来就要解决这两个问题:

  • 1.列表项虽然只有这么多,但是必须要一直出现在容器里,不能划着划着就没东西了
  • 2.列表数量可以不变,但是里面的数据要变化啊,不然我一直看到的都是最前面的 15 个数据项有啥用?

第一个问题还是比较好解决的,我们只需要监听父容器的滚动事件,拿到当前滚动的距离,让列表项全部按照这个距离往下 translate 就行。下面是代码:

const ITEM_COUNT = 10000;
const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 500;
const listData = new Array(ITEM_COUNT).fill(0).map((_, i) => i);
const VirtualList = () => {
  const [visibleList, _setVisibleList] = useState(listData.slice(0, 15));
  const [scrollTop, setScrollTop] = useState(0);

  const onScroll = (e) => {
    const s = e.target.scrollTop;
    setScrollTop(s);
  };
  return (
    <div className=" h-[calc(100vh-88px)]">
      <div
        className="bg-amber-100 relative overflow-auto"
        style={{ height: CONTAINER_HEIGHT }}
        onScroll={onScroll}
      >
        <div
          className="absolute top-0 left-0 right-0"
          style={{ height: ITEM_COUNT * ITEM_HEIGHT }}
        />
        <div style={{ transform: `translateY(${scrollTop}px)` }}>
          {visibleList.map((item) => {
            return (
              <div
                key={item}
                className="p-4 border-b"
                style={{ height: ITEM_HEIGHT }}
              >
                #{item}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

效果:

3.gif

你会发现,不管我们怎么滑动,列表项都会跟着我们滚动而移动,造成的假象就是这个列表里好多列表项,而不是仅仅只有 15 个。但事实上,里面确实只渲染了 15 个。现在的问题是,怎么让列表项里的内容随着滚动而发生变化。

其实我们再去看一开始的渲染了一万个列表项的 demo 可以发现,我们在滚动的过程中,需要知道当前要展示哪些元素,因为我们一定只渲染 15 个,因此其实我们只要知道看得到的列表项中开头那个是什么序号,就可以把 visibleList 算出来了。

因此我们维护一个 startIndex 数据, 怎么计算这个数据呢?其实也非常简单,因为这个容器和里面的列表项都是定高的,我们有 scrollTop 这个数据就直接除以列表项高度就是 startIndex 了。于是有了如下代码:

import { useState } from "react";

const ITEM_COUNT = 10000;
const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 500;
const listData = new Array(ITEM_COUNT).fill(0).map((_, i) => i);
const VirtualList = () => {
  const [scrollTop, setScrollTop] = useState(0);
  const [startIndex, setStartIndex] = useState(0);
  const onScroll = (e) => {
    const s = e.target.scrollTop;
    setStartIndex(Math.floor(s / ITEM_HEIGHT));
    setScrollTop(s);
  };
  const visibleList = listData.slice(startIndex, startIndex + 15);
  return (
    <div className=" h-[calc(100vh-88px)]">
      <div
        className="bg-amber-100 relative overflow-auto"
        style={{ height: CONTAINER_HEIGHT }}
        onScroll={onScroll}
      >
        <div
          className="absolute top-0 left-0 right-0"
          style={{ height: ITEM_COUNT * ITEM_HEIGHT }}
        />
        <div style={{ transform: `translateY(${scrollTop}px)` }}>
          {visibleList.map((item) => {
            return (
              <div
                key={item}
                className="p-4 border-b"
                style={{ height: ITEM_HEIGHT }}
              >
                #{item}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

效果:

4.gif

可以看到这个效果还是很不错的,好像这个容器里真的有 1 万个数据项一样,但其实打开控制台发现一直都是只有 15 个数据项。

看到 demo 中列表项在滑动过程中存在抖动的问题,我这里觉得是把 scrollTop 设置成了响应式数据的原因,其实 scrollTop 直接在函数组件内让 startIndex 算出来就可以了。优化代码:

import { useCallback, useState } from "react";

const ITEM_COUNT = 10000;
const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 500;
const listData = new Array(ITEM_COUNT).fill(0).map((_, i) => i);
const VirtualList = () => {
  const [startIndex, setStartIndex] = useState(0);
  const scrollTop = startIndex * ITEM_HEIGHT;
  const onScroll = useCallback((e) => {
    const s = e.target.scrollTop;
    setStartIndex(Math.floor(s / ITEM_HEIGHT));
  }, []);
  const visibleList = listData.slice(startIndex, startIndex + 15);
  return (
    <div className=" h-[calc(100vh-88px)]">
      <div
        className="bg-amber-100 relative overflow-auto"
        style={{ height: CONTAINER_HEIGHT }}
        onScroll={onScroll}
      >
        <div
          className="absolute top-0 left-0 right-0"
          style={{ height: ITEM_COUNT * ITEM_HEIGHT }}
        />
        <div style={{ transform: `translateY(${scrollTop}px)` }}>
          {visibleList.map((item) => {
            return (
              <div
                key={item}
                className="p-4 border-b"
                style={{ height: ITEM_HEIGHT }}
              >
                #{item}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

现在就非常丝滑了:

5.gif

总结

现在只是一个等高虚拟列表的 demo,如果不等高的话,就比较难了,可能需要引入列表项的平均预测高度,做初始化,用 useLayoutEffect 获取 dom 的真实高度,核心还是如何获取当前的 startIndex,等高获取这个数据的时间复杂度是 O(1),除一下就出来了,但是不考虑优化的话,不等高虚拟列表的时间复杂度是 O(n),但是通过二分查找的话,就可以降到 O(logn),这一步优化可以说非常牛了,有空再出一篇文章讲不等高的情况。除了不等高的扩展以外,其实虚拟列表还有传送的功能可以扩展,比如从任意位置直接传送到列表底部,或者指定某个 index 传到对应的列表项,传送本身不难,如果要加入不同的动画效果,就有点麻烦了。如果感兴趣的话,可以到我的一个 demo 网站上看一看,里面有很多前端面试中让你直接手写的效果:demo 网址