使用hooks实现虚拟列表并通过节流、增加缓冲区进行性能优化

662 阅读8分钟

什么是虚拟列表?

虚拟列表相对于普通列表的区别就是,虚拟列表只展示可视区需要展示的列表项,并通过监听滚动实时更新列表项,从而避免对整个列表进行渲染。对于大数据量的列表展示,虚拟列表是一个很好的解决方案。

虚拟列表dom结构组成

image.png

<!-- 可视区(container) 滚动容器 -->
  <div ref="containerRef" class="container" @scroll="scrollEvent">
    <!-- 内容虚拟撑开区(phantom) -->
    <div class="phantom"></div>
    <!-- 内容可见区(content) -->
    <div class="content">
      <div v-for="item in actualRenderData" :key="item.index" class="content-item">
        {{ item.text }}
      </div>
    </div>
  </div>
.container {
  width: 300px;
  height: 500px;
  -webkit-overflow-scrolling: touch;
  overflow: auto;
  position: relative;
  background-color: aqua;
}
.content {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  background-color: yellow;
}
  1. 可视区:即滚动容器,需指定高度height,并添加overflow: auto css样式,从而实现滚动;
  2. 虚拟区:用来撑开可视区高度,它的高度为列表中所有子项的高度之和,我们借助它来实现可视区的滚动效果;
  3. 内容区:展示实际渲染的列表项,滚动的时候内容区里面的元素是动态变化的,且为了实现渲染的数据始终处于可视区,针对内容区进行处理,给它设置 transform: translateY(n)  值。

使用hooks实现虚拟列表

不封装成组件的目的就是为了让此方法更加的通用,不局限外部使用的第三方组件或自己封装的组件,让其既支持 table 形式,又让其支持普通的 list 形式,还能让其支持 select 形式

完整代码

// useVirtualList.ts 优化虚拟列表(增加缓冲区以及滚动节流)
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
import type { Ref } from "vue";

interface Config {
  data: Ref<any[]>; // 数据源
  scrollContainer: string; // 滚动容器的元素选择器(可视区)
  actualHeightContainer: string; // 用于撑开高度的元素选择器(虚拟区)
  translateContainer: string; // 用于偏移的元素选择器(内容区)
  itemContainer: string; // 列表项选择器(内容区列表项)
  itemHeight: number; // 列表项高度(根据具体使用场景进行估算 用于给撑开高度的元素设置初始高度)
  bufferRatio: number; // 缓冲区比例(缓冲区数据与可视区数据的比例)
}

type HtmlElType = HTMLElement | null;
type timeout = string | number | undefined | null;

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

  let size = 10; // 可视区展示列表项个数

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

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

    // 获取可视区的高度,用于计算可视区的展示列表项个数
    // @ts-expect-error 这里是获取元素高度,所以可以忽略类型错误
    size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);

    dataSource = config.data.value;
    // 根据滚动高度计算需要渲染的数据
    updateActualRenderData(0);
  });

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

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

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

  // 获取缓存高度,无缓存,取配置项的 itemHeight
  const getItemHeightFromCache = (index: number | string) => {
    const val = renderedItemsCache[index];
    return val === void 0 ? config.itemHeight : val;
  };
  /**
   * 更新虚拟区实际高度
   */
  const updateActualHeight = () => {
    // 计算所有项目的总高度
    const totalHeight = dataSource.reduce((accumulator, _, index) => {
      try {
        // 从缓存中获取每个项目的高度并累加
        return accumulator + getItemHeightFromCache(index);
      } catch (error) {
        // 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
        console.error(`Error getting height for item ${index}:`, error);
        return accumulator;
      }
    }, 0);

    // 更新容器的高度
    if (actualHeightContainerEl) {
      // 如果容器元素存在,则设置其高度为计算出的总高度
      actualHeightContainerEl.style.height = totalHeight + "px";
    } else {
      // 如果容器元素未定义,则打印错误信息
      console.error("actualHeightContainerEl is not defined");
    }
  };

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

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

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

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

      // 更新偏移值
      getOffsetY(start);
    });
  };

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

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

  //   // 根据滚动高度找到渲染列表的开始位置
  //   for (let i = 0; i < dataSource.length; i++) {
  //     offsetHeight += getItemHeightFromCache(i);

  //     if (offsetHeight >= scrollTop) {
  //       startIndex = i;
  //       endIndex = i + size;
  //       break;
  //     }
  //   }

  //   // 起始缓冲数量
  //   const aboveCount = Math.min(startIndex, size * config.bufferRatio);
  //   // 终止缓冲数量
  //   const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
  //   // 计算得出的渲染数据
  //   let start = startIndex - aboveCount;
  //   const end = endIndex + belowCount;
  //   actualRenderData.value = dataSource.slice(start, end);

  //   // 缓存渲染列表中各个列表项高度
  //   updateRenderedItemCache(start);
  // };
  const updateActualRenderData = (scrollTop: number) => {
    // 异常处理
    if (!dataSource || !config) {
      console.error("dataSource or config is undefined");
      return;
    }

    let startIndex = 0;
    let endIndex = 0;
    let offsetHeight = 0;

    // 使用二分查找来加速 startIndex 的计算
    let low = 0;
    let high = dataSource.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      offsetHeight = getAccumulatedHeight(mid);
      if (offsetHeight >= scrollTop) {
        high = mid - 1;
      } else {
        low = mid + 1;
      }
    }
    startIndex = low;
    endIndex = startIndex + size;

    // 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
    startIndex = Math.max(0, startIndex);
    endIndex = Math.min(dataSource.length, endIndex);

    // 起始缓冲数量
    const aboveCount = Math.min(startIndex, size * config.bufferRatio);
    // 终止缓冲数量
    const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
    // 计算得出的渲染数据
    let start = startIndex - aboveCount;
    const end = endIndex + belowCount;
    actualRenderData.value = dataSource.slice(start, end);

    // 缓存渲染列表中各个列表项高度
    updateRenderedItemCache(start);

    nextTick(() => {
      // 更新偏移值
      getOffsetY(start);
    });
  };

  // 辅助函数:获取累积高度
  const getAccumulatedHeight = (index: number): number => {
    let height = 0;
    for (let i = 0; i <= index; i++) {
      height += getItemHeightFromCache(i);
    }
    return height;
  };

  // 获取偏移量
  const getOffsetY = start => {
    let startOffset = 0;
    if (start >= 1) {
      startOffset = new Array(start).fill("").reduce((acc, _, index) => {
        return acc + getItemHeightFromCache(index);
      }, 0);
    }
    translateContainerEl!.style.transform = `translateY(${startOffset}px)`;
  };

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

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

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

  function throttle(fn, delay) {
    // last为上一次触发回调的时间, timer是定时器
    let last = 0,
      timer: timeout = null;
    // 将throttle处理结果当作函数返回

    return function (...args) {
      // 保留调用时的this上下文
      const context = this;
      // 保留调用时传入的参数
      // let args = arguments;
      // 记录本次触发回调的时间
      let now = +new Date();

      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last < delay) {
        // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
        clearTimeout(timer);
        timer = setTimeout(function () {
          last = now;
          fn.apply(context, args);
        }, delay);
      } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now;
        fn.apply(context, args);
      }
    };
  }

  return { actualRenderData };
}

代码解析

<script setup>
import { ref } from "vue";
import { faker } from "@faker-js/faker";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";

// 列表数据
const listData = ref(new Array(1000).fill({}).map((item, index) => ({ index, text: index + "、" + faker.lorem.sentences() })));
const { actualRenderData } = useOptimizeVirtualList({
  data: listData, // 列表项数据
  scrollContainer: ".container", // 滚动容器
  actualHeightContainer: ".phantom", // 渲染实际高度的容器
  translateContainer: ".content", // 需要偏移的目标元素,
  itemContainer: ".content-item", // 列表项
  itemHeight: 100, // 列表项的大致高度
  bufferRatio: 1 // 缓冲比例
});
</script>

使用hooks进行封装,在需要使用虚拟列表的Vue文件中,引入@/hooks/useOptimizeVirtualList,调用useOptimizeVirtualList方法,传入列表数据、可视区/虚拟区/内容区/列表项的元素选择器、估算的单个列表项大致高度以及缓冲比例,即可得到内容区需要渲染的列表数据。

interface Config {
  data: Ref<any[]>; // 数据源
  scrollContainer: string; // 滚动容器的元素选择器(可视区)
  actualHeightContainer: string; // 用于撑开高度的元素选择器(虚拟区)
  translateContainer: string; // 用于偏移的元素选择器(内容区)
  itemContainer: string; // 列表项选择器(内容区列表项)
  itemHeight: number; // 列表项高度(根据具体使用场景进行估算 用于给撑开高度的元素设置初始高度)
  bufferRatio: number; // 缓冲区比例(缓冲区数据与可视区数据的比例)
}

使用ts定义传入的数据类型

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

    // 获取可视区的高度,用于计算可视区的展示列表项个数
    // @ts-expect-error 这里是获取元素高度,所以可以忽略类型错误
    size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);

    dataSource = config.data.value;
    // 根据滚动高度计算需要渲染的数据
    updateActualRenderData(0);
  });

在onMounted生命周期即组件挂载完成后

  1. 通过document.querySelector以及传入的元素选择器获取dom元素;
  2. 根据可视区的高度以及传入的单个列表项大致高度,算出可视区展示的列表项个数;
  3. 用变量dataSource存储传入的列表数据,调用updateActualRenderData方法算出内容区需要渲染的列表项、缓存渲染列表中各个列表项高度并更新内容区的偏移值**transform: translateY(n)** 。
const updateActualRenderData = (scrollTop: number) => {
    // 异常处理
    if (!dataSource || !config) {
      console.error("dataSource or config is undefined");
      return;
    }

    let startIndex = 0;
    let endIndex = 0;
    let offsetHeight = 0;

    // 使用二分查找来加速 startIndex 的计算
    let low = 0;
    let high = dataSource.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      offsetHeight = getAccumulatedHeight(mid);
      if (offsetHeight >= scrollTop) {
        high = mid - 1;
      } else {
        low = mid + 1;
      }
    }
    startIndex = low;
    endIndex = startIndex + size;

    // 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
    startIndex = Math.max(0, startIndex);
    endIndex = Math.min(dataSource.length, endIndex);

    // 起始缓冲数量
    const aboveCount = Math.min(startIndex, size * config.bufferRatio);
    // 终止缓冲数量
    const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
    // 计算得出的渲染数据
    let start = startIndex - aboveCount;
    const end = endIndex + belowCount;
    actualRenderData.value = dataSource.slice(start, end);

    // 缓存渲染列表中各个列表项高度
    updateRenderedItemCache(start);

    nextTick(() => {
      // 更新偏移值
      getOffsetY(start);
    });
  };

  // 辅助函数:获取累积高度
  const getAccumulatedHeight = (index: number): number => {
    let height = 0;
    for (let i = 0; i <= index; i++) {
      height += getItemHeightFromCache(i);
    }
    return height;
  };
  1. 在updateActualRenderData方法中使用二分法快速计算当前滚动高度scrollTop对应的 startIndex以及endIndex;
  2. 根据传入的缓冲比例算出前置缓冲数量aboveCount以及后置缓冲数量belowCount,起始索引startIndex需要减去加入的前置缓冲元素aboveCount,结束的索引endIndex需要加上后置缓冲区的元素belowCount,从而通过dataSource.slice(start, end)计算出渲染数据。增加缓冲区可以解决快速滚动时,可能会出现子项元素未占满可视区的现象。
  /**
   * 更新虚拟区实际高度
   */
  const updateActualHeight = () => {
    // 计算所有项目的总高度
    const totalHeight = dataSource.reduce((accumulator, _, index) => {
      try {
        // 从缓存中获取每个项目的高度并累加
        return accumulator + getItemHeightFromCache(index);
      } catch (error) {
        // 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
        console.error(`Error getting height for item ${index}:`, error);
        return accumulator;
      }
    }, 0);

    // 更新容器的高度
    if (actualHeightContainerEl) {
      // 如果容器元素存在,则设置其高度为计算出的总高度
      actualHeightContainerEl.style.height = totalHeight + "px";
    } else {
      // 如果容器元素未定义,则打印错误信息
      console.error("actualHeightContainerEl is not defined");
    }
  };
  
  // 更新已渲染列表项的缓存高度
  const updateRenderedItemCache = (index: number) => {
    // 当所有元素的实际高度更新完毕,就不需要重新计算高度
    const shouldUpdate = Object.keys(renderedItemsCache).length < dataSource.length;
    if (!shouldUpdate) return;

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

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

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

在updateActualRenderData方法算出内容区需要渲染的列表项后,调用updateRenderedItemCache更新当前内容区渲染的列表项的缓存高度,再调用updateActualHeight更新虚拟区实际高度。

  // 获取偏移量
  const getOffsetY = start => {
    let startOffset = 0;
    if (start >= 1) {
      startOffset = new Array(start).fill("").reduce((acc, _, index) => {
        return acc + getItemHeightFromCache(index);
      }, 0);
    }
    translateContainerEl!.style.transform = `translateY(${startOffset}px)`;
  };

通过getOffsetY设置内容区的偏移量,因为内容区是相对于可视区绝对定位的,如果不设置偏移量,内容区跟随滚动条滚动而脱离可视区。至于传入的参数为啥是startIndex - aboveCount,而不是startIndex,我一直没想通,脑容量不够!!!

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

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

  function throttle(fn, delay) {
    // last为上一次触发回调的时间, timer是定时器
    let last = 0,
      timer: timeout = null;
    // 将throttle处理结果当作函数返回

    return function (...args) {
      // 保留调用时的this上下文
      const context = this;
      // 保留调用时传入的参数
      // let args = arguments;
      // 记录本次触发回调的时间
      let now = +new Date();

      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last < delay) {
        // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
        clearTimeout(timer);
        timer = setTimeout(function () {
          last = now;
          fn.apply(context, args);
        }, delay);
      } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now;
        fn.apply(context, args);
      }
    };
  }

通过节流限制监听可视区滚动事件的频率,避免短时间会触发多次计算,从而造成页面卡顿等性能问题。

总结

通过增加缓冲区解决快速滚动时可视区展示不完整的问题,但是通过getOffsetY计算内容区的偏移量时至于传入的参数为啥是startIndex - aboveCount,而不是startIndex,我一直没想通,脑容量不够!!!

参考:
Vue3 封装不定高虚拟列表 hooks,复用性更好!
一文弄懂虚拟列表原理及实现(图解&码上掘金)