Vue3 封装不定高虚拟列表 hooks,复用性更好!

19,273 阅读5分钟

前言

某日某时某刻某分某秒,收到 小 A 同学 的消息,原因是他司有人反馈某项目中页面渲染内容太慢、太卡,且后端开发也贴出接口响应很快的日志,于是乎这个 优化 的小任务就落到了他头上。

image.png

经过简单询问得知:

  • 页面上某个 table 组件 渲染的数据 不是分页的,接口将查到的所有符合的数据一股脑返回给了前端,约几万条数据
  • 前端页面表现是 渲染慢、交互卡

模拟效果(渲染 3w 数据)如下:

1.gif

治标不治本 — 滚动加载

当然 小 A 同学 很快就想到了自己实现滚动加载:

  • 每次渲染20条数据,当滚动条 触底后继续渲染

于是马上进行提测,而测试同学也非常的敬业,一直滚动加载到了 几千条 数据,此时虽然在渲染表格项的时候没有出现卡顿,但是点击表格项时需要弹窗的这个交互,却又开始卡顿了,模拟效果如下(此处省略分批渲染):

1.gif

table 慢元素

由于 table 元素在渲染时需要 更多的计算资源,这其中需要计算表格的布局、单元格的大小和位置等,这可能会导致在 某些情况 下 table 元素的渲染速度较慢,因此 table 元素也叫 慢元素。

37D5CC67.png

现在的问题显然由于使用 慢元素渲染大数据 而造成渲染卡顿、交互不流畅的问题,而前面的 分页加载 虽然可以解决 前期渲染卡顿 的问题,却不能解决 后期弹窗交互卡顿 的问题,原因就是 最后实际需要渲染的慢元素根本没有减少。

那有什么办法能 保证每次实际渲染的数量不会递增 呢?

有,就是 只渲染可视区及其周边的数据,而这也就是 虚拟列表 的核心。

虚拟列表

接下来我们会封装一个和虚拟列表相关的 hooks,不封装成组件的目的就是为了让此方法更加的通用,不局限外部使用的第三方组件或自己封装的组件,让其既支持 table 形式,又让其支持普通的 list 形式,还能让其支持 select 形式。

087082B2.gif

虚拟列表 — 定高

要实现虚拟列表需要考虑如下三个方面:

  • 滚动模拟

    • 普通列表渲染 是 可滚动 的,滚动产生的条件就是 每次渲染数量会递增,那么 虚拟列表 就需要在保证 每次渲染数量不递增 的情况下 支持滚动
  • 渲染正确的内容

    • 保证用户在向上或向下滚动的过程中数据的 渲染内容是正确的,只有这样看起来才和 普通列表 表现一致
  • 渲染的数据需要在可视区

    • 虚拟列表 支持滚动之后,就需要保证渲染的数据一直存在于 可视区,而不是随着滚动到可视区之外

1.gif

这里在引入三个名称和配图,方便进行理解,具体如下:

  • 滚动容器
    • 顾名思义,就是为了实现滚动,所以需要设置 height 固定高度 或 最大高度 max-height
  • 渲染实际高度的容器
    • 为了实现模拟滚动,需要将实际高度的值,即 每个列表项高度之和 设置在某个元素上,这样就可以超过 滚动容器的高度,从而产生滚动效果
  • 偏移容器
    • 要实现渲染的数据始终处于可视区,那么可以针对 包裹着所有列表项的元素 进行处理,也就是将它的 transform: translateY(n) 值设置为 当前已滚动的高度 scrollTop 即可
    • 同时要保证每个滚动位置要渲染正确的数据,那么最简单的方式就是,根据 当前已滚动的高度 scrollTop 除以 单个列表项的高低 height,计算出当前需要渲染的 起始索引 startIndex,假设每次需要渲染 20 条 数据,很容易算出 结束索引 endIndex,这样就可以知道当前滚动位置需要渲染的数据范围是什么

image.png

不到 100 行即可拥有虚拟滚动,具体实现如下:

// useVirtualList.ts

import { ref, onMounted, onBeforeUnmount, watch, computed} from "vue";

import type { Ref } from "vue";

interface Config {
  data: Ref<any[]>; // 数据
  itemHeight: number;// 列表项高度
  size: number;// 每次渲染数据量
  scrollContainer: string;// 滚动容器的元素选择器
  actualHeightContainer: string;// 用于撑开高度的元素选择器
  tranlateContainer: string;// 用于偏移的元素选择器
}

type HtmlElType = HTMLElement | null;

export default function useVirtualList(config: Config) {
  // 获取元素
  let actualHeightContainerEl: HtmlElType  = null,
  tranlateContainerEl: HtmlElType = null,
  scrollContainerEl: HtmlElType = null;

  onMounted(() => {
    actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
    scrollContainerEl = document.querySelector(config.scrollContainer);
    tranlateContainerEl = document.querySelector(config.tranlateContainer);
  });

  // 通过设置高度,模拟滚动
  watch(() => config.data.value, (newVal) => {
    actualHeightContainerEl!.style.height = newVal.length * config.itemHeight + "px";
  });

  // 实际渲染的数据
  const startIndex = ref(0);
  const endIndex = ref(config.size - 1);
  const actualRenderData = computed(() => {
    return config.data.value.slice(startIndex.value, endIndex.value + 1);
  });

  // 滚动事件
  const handleScroll = (e) => {
    const target = e.target;
    const { scrollTop, clientHeight, scrollHeight } = target;

    // 边界控制:实际触底,且页面正常渲染全部数据时,不再触发后续计算,防止触底抖动
    if (
      scrollHeight <= scrollTop + clientHeight &&
      endIndex.value >= config.data.value.length
    ) {
      return;
    }

    // 保证数据渲染一直在可视区
    tranlateContainerEl.style.transform = `translateY(${scrollTop}px)`;

    // 渲染正确的数据
    startIndex.value = Math.floor(scrollTop / config.itemHeight);
    endIndex.value = startIndex.value + config.size;
  };

  // 注册滚动事件
  onMounted(() => {
    scrollContainerEl?.addEventListener("scroll", handleScroll);
  });

  // 移除滚动事件
  onBeforeUnmount(() => {
    scrollContainerEl?.removeEventListener("scroll", handleScroll);
  });

  return { actualRenderData };
}

针对 自定义列表结构 应符合如下结构:

  <ul class="scroll-container"> // 滚动容器
    <div class="actual-height-container">// 渲染实际高度的容器
      <div class="tranlate-container"> // 用于偏移的容器
        <li v-for="(item, i) in actualRenderData">
          ...
        </li>
      </div>
    </div>
  </ul>

针对 el-table 组件 的选择器可用如下的方式:

const { actualRenderData } = useVirtualList({
  data: tableData, // 列表项数据
  itemHeight: 100,
  size: 10,
  scrollContainer: ".el-scrollbar__wrap", // 滚动容器
  actualHeightContainer: ".el-scrollbar__view", // 渲染实际高度的容器
  tranlateContainer: ".el-table__body", // 需要偏移的目标元素
});

最终演示效果如下,演示效果是 3w 条数据,实际上 10w 条数据也是很丝滑:

1.gif

虚拟列表 — 不定高

假如列表项高度是固定的,那么 实际列表渲染总高度 = 列表项数量 * 单个列表项高度,然而列表项的内容并不总是一致的。

首先,不定高 相对于 定高 场景下存在几个不确定的内容:

  • 每个列表项 实际渲染高度无法直接获取
  • 实际渲染总高度 无法直接计算
  • 滚动时对应需要渲染数据的开始索引 startIndex 无法直接计算

下面我们就依次解决这几个问题即可。

QQ_1726383574009.png

nextTick — 解决列表项高度未知性

在实际渲染列表项之前,无法获取到对应列表项的高度,那么我们就等到这个列表渲染后,在获取它的高度就可以了。

而在 Vue 中能够帮我们实现这个目的的就是 nextTick,回顾官方文档对其的描述:

  • 当 Vue 中 更改响应式状态 时,最终的 DOM 更新 并 不是同步生效 的,而是由 Vue 将它们 缓存在一个队列 中,直到下一个 tick 才一起执行,这样是为了确保每个组件 无论发生多少状态改变,都 仅执行一次更新

也就是说,当我们计算出需要 实际渲染数据 actualRenderData 时,基于响应式的存在,这个数据最终会渲染成页面上的 Dom,此时在 nextTick 中就能获取到已渲染到页面上的列表项的高度了。

nextTick(() => {
  // 获取所有列表项元素
  const Items: HTMLElement[] = Array.from(
    document.querySelectorAll(config.itmeContainer)
  );
  ...
};

cache 缓存 — 解决实际渲染总高度未知性

上面我们实现了不定高列表项高度的获取,但是单纯这样还是无法获取到 实际渲染的总高度,因为每次只是渲染 部分数据,所以我们需要把每次渲染好的列表项高度给存起来,建立 缓存 cache,缓存的对应关系就设置为:

  • cache 的 key 就是当前列表项在 数据源中的 index
  • cache[key] 的 value 就是当前列表项的 渲染高度

更新好缓存后,所有列表项的总渲染高度就好计算了,只需要 遍历数据源,拿到对应的 index 再去 缓存 cache 中获取高度,然后累加即可。

值得注意的是,初始化时 缓存 cache 为空,此时无法从中获取的高度,因此我们需要给定一个接近列表的高度值,当缓存中取不到值时,就使用此高度参与计算即可。

  // 更新已渲染列表项的缓存高度
  const updateRenderedItemCache = (index: number) => {
    nextTick(() => {
      // 获取所有列表项元素
      const Items: HTMLElement[] = Array.from(
        document.querySelectorAll(config.itmeContainer)
      );

      // 进行缓存
      Items.forEach((el) => {
        if (!RenderedItemsCache[index]) {
          RenderedItemsCache[index] = el.offsetHeight;
        }
        index++;
      });
      ...
    });
  };

scrollTop + cache 缓存 — 解决列表 startIndex 未知性

要计算当前需要渲染数据的 开始索引 startIndex,在不定高的场景下,我们可以 从 cache 缓存 中依次计算列表项的高度之和 offsetHeight,直到 offsetHeight >= scrollTop,那么此时 该列表项 index 就可以作为当前需要渲染数据的 开始索引 startIndex。

QQ_1726391461870.png

值得注意的是,当我们计算出了 offsetHeight 后,其实它就是列表项需要偏移的值,只不过初始化 scrollTop = 0 时实际上是不需要偏移的,但此时计算出 offsetHeight 的值为 开始索引 startIndex 列表项的高度,因此在实际偏移是我们需要减去这个值。

   // 更新实际渲染数据
  const updateRenderData = (scrollTop: number) => {
    let startIndex = 0;
    let offsetHeight = 0;

    for (let i = 0; i < dataSource.length; i++) {
      offsetHeight += getItemHeightFromCache(i);

      if (offsetHeight >= scrollTop) {
        startIndex = i;
        break;
      }
    }

    // 计算得出的渲染数据
    actualRenderData.value = dataSource.slice(
      startIndex,
      startIndex + config.size
    );

    // 缓存最新的列表项高度
    updateRenderedItemCache(startIndex);

    // 更新偏移值
    updateOffset(offsetHeight - getItemHeightFromCache(startIndex));
  };

效果演示

普通 List 列表,如下:

const { actualRenderData } = useVirtualList({
  data: tableData, // 列表项数据
  scrollContainer: ".scroll-container", // 滚动容器
  actualHeightContainer: ".actual-height-container", // 渲染实际高度的容器
  translateContainer: ".translate-container", // 需要偏移的目标元素,
  itmeContainer: '.item',// 列表项
  itemHeight: 50,// 列表项的大致高度
  size: 10,// 单次渲染数量
});

list.gif

el-table 组件,如下:

const { actualRenderData } = useVirtualList({
  data: tableData, // 列表项数据
  scrollContainer: ".el-scrollbar__wrap", // 滚动容器
  actualHeightContainer: ".el-scrollbar__view", // 渲染实际高度的容器
  tranlateContainer: ".el-table__body", // 需要偏移的目标元素,
  itmeContainer: '.el-table__row',// 列表项
  itemHeight: 50,// 列表项的大致高度
  size: 10,// 单次渲染数量
});

table.gif

完整代码

// useVirtualList.ts

import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import type { Ref } from "vue";

interface Config {
  data: Ref<any[]>; // 数据源
  scrollContainer: string; // 滚动容器的元素选择器
  actualHeightContainer: string; // 用于撑开高度的元素选择器
  translateContainer: string; // 用于偏移的元素选择器
  itmeContainer: string;// 列表项选择器
  itemHeight: number; // 列表项高度
  size: number; // 每次渲染数据量
}

type HtmlElType = HTMLElement | null;

export default function useVirtualList(config: Config) {
  // 获取元素
  let actualHeightContainerEl: HtmlElType = null,
    translateContainerEl: HtmlElType = null,
    scrollContainerEl: HtmlElType = null;

  onMounted(() => {
    actualHeightContainerEl = document.querySelector(
      config.actualHeightContainer
    );
    scrollContainerEl = document.querySelector(config.scrollContainer);
    translateContainerEl = document.querySelector(config.translateContainer);
  });

  // 数据源,便于后续直接访问
  let dataSource: any[] = [];

  // 数据源发生变动
  watch(
    () => config.data.value,
    (newVla) => {
      // 更新数据源
      dataSource = newVla;

      // 计算需要渲染的数据
      updateRenderData(0);
    }
  );

  // 更新实际高度
  const updateActualHeight = () => {
    let actualHeight = 0;
    dataSource.forEach((_, i) => {
      actualHeight += getItemHeightFromCache(i);
    });

    actualHeightContainerEl!.style.height = actualHeight + "px";
  };

  // 缓存已渲染元素的高度
  const RenderedItemsCache: any = {};

  // 更新已渲染列表项的缓存高度
  const updateRenderedItemCache = (index: number) => {
    // 当所有元素的实际高度更新完毕,就不需要重新计算高度
    const shouldUpdate =
      Object.keys(RenderedItemsCache).length < dataSource.length;
    if (!shouldUpdate) return;

    nextTick(() => {
      // 获取所有列表项元素
      const Items: HTMLElement[] = Array.from(
        document.querySelectorAll(config.itmeContainer)
      );

      // 进行缓存
      Items.forEach((el) => {
        if (!RenderedItemsCache[index]) {
          RenderedItemsCache[index] = el.offsetHeight;
        }
        index++;
      });

      // 更新实际高度
      updateActualHeight();
    });
  };

  // 获取缓存高度,无缓存,取配置项的 itemHeight
  const getItemHeightFromCache = (index: number | string) => {
    const val = RenderedItemsCache[index];
    return val === void 0 ? config.itemHeight : val;
  };

  // 实际渲染的数据
  const actualRenderData: Ref<any[]> = ref([]);

  // 更新实际渲染数据
  const updateRenderData = (scrollTop: number) => {
    let startIndex = 0;
    let offsetHeight = 0;

    for (let i = 0; i < dataSource.length; i++) {
      offsetHeight += getItemHeightFromCache(i);

      if (offsetHeight >= scrollTop) {
        startIndex = i;
        break;
      }
    }

    // 计算得出的渲染数据
    actualRenderData.value = dataSource.slice(
      startIndex,
      startIndex + config.size
    );

    // 缓存最新的列表项高度
    updateRenderedItemCache(startIndex);

    // 更新偏移值
    updateOffset(offsetHeight - getItemHeightFromCache(startIndex));
  };

  // 更新偏移值
  const updateOffset = (offset: number) => {
    translateContainerEl!.style.transform = `translateY(${offset}px)`;
  };

  // 滚动事件
  const handleScroll = (e: any) => {
    // 渲染正确的数据
    updateRenderData(e.target.scrollTop);
  };

  // 注册滚动事件
  onMounted(() => {
    scrollContainerEl?.addEventListener("scroll", handleScroll);
  });

  // 移除滚动事件
  onBeforeUnmount(() => {
    scrollContainerEl?.removeEventListener("scroll", handleScroll);
  });

  return { actualRenderData };
}

最后

综上,我们通过 封装 hooks 将虚拟列表核心逻辑进行抽离,就不用局限于某个组件中,这样就可以支持第三方组件库中的 List、Select、Table 等组件,同时也能够支持自定义组件,只要其结构符合即可,这比封装成 虚拟列表组件 更合适。

image.png