【全网最通俗易懂】虚拟列表1-(基础篇):渲染十万条数据不卡顿(附demo和源码)

912 阅读5分钟

大家好,我是 前端架构师 - 大卫

更多优质内容请关注微信公众号 @程序员大卫

初心为助前端人🚀,进阶路上共星辰✨,

您的点赞👍与关注❤️,是我笔耕不辍的灯💡。

虚拟列表-示例.gif

今天,我将用最通俗易懂的语言,与大家分享一项常见的前端优化技术。—— 虚拟列表

在线地址预览: codesandbox.io/p/devbox/si…

这篇文章主要帮助大家理解 虚拟列表核心原理。后续我会再写一篇文章,支持“不定高虚拟列表”、动态加载图片、并能动态监听高度!🎉🎉🎉

背景

假设我们需要渲染 100,000 个元素,如果直接渲染所有内容,DOM 节点会过多,占用大量内存,从而导致浏览器性能显著下降,甚至可能崩溃。

核心原理

虚拟列表的核心原理非常简单:只渲染“可视区域”内的 DOM 节点,通过这一方式显著减少 DOM 的数量。

如下图,只渲染 item-99~item-104这6个元素。

虚拟列表-核心原理.png

实现

需要解决的问题

在实现虚拟列表之前,我们需要思考以下几个问题:

  1. 虚拟列表只保留可视区域内的元素,那么如何让滚动条看起来和完整列表一样,保持原有的滚动体验?
  2. 可视区域内最多能显示多少个列表项?
  3. 渲染的列表内容如何确定?
  4. 渲染区域的偏移量如何计算?

接下来我们逐一解决这些问题。

问题 1:如何保持滚动条样式一致?

可以通过定义一个占位元素来撑开容器的高度,从而让滚动条看起来与完整列表一致。

那这个元素的高度是多少,很容易得出其总高度等于每项高度乘以列表项总数

const containerHeight = itemSize * listData.length;

此外,需要将这个元素设置为绝对定位,并通过 z-index: -1 将其隐藏:

.placeholder {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

问题 2:“可视区域”最多显示多少项?

可视区域最多显示的列表项数量,可通过将可视区域高度除以每项高度来得出:

const visibleCount = Math.ceil(screenHeight / itemSize)

问题 3:如何确定渲染的列表内容?

要确定渲染的内容,需要计算起始索引结束索引

假设每项高度为 100,滚动高度为 360。根据下图可知:

  • 起始索引 = 滚动高度 ÷ 每项高度
  • 结束索引 = 起始索引 + 可视区域内的项目总数

渲染的列表内容.png

具体代码如下:

// 起始索引
const startIndex = Math.floor(scrollTop / itemSize)

// 结束索引
const endIndex = startIndex + visibleCount;

最终,渲染的列表内容可以通过以下方式获取:

const renderedItems = listData.slice(startIndex, endIndex);

问题 4:如何计算“偏移量”?

偏移量的计算是实现虚拟列表的关键,理解了这个就真正掌握了虚拟列表。

假设:

  • 每项高度 itemSize 为 100;
  • 用户滚动高度 scrollTop 为 340。

普通列表滚动的页面渲染如下图:

普通列表滚动.png

而在未设置偏移量的虚拟列表中,页面如下。

虚拟列表-未设置偏移量.png

为什么列表从item-4开始,因为虚拟列表仅渲染“可视区域”内的内容。

所以必须设置偏移量,把内容拉回到可视区域。设置完偏移量后,页面渲染如下:

虚拟列表-设置偏移量.png

偏移量的计算方式非常简单:它等于滚动的项数乘以每项高度

const offset = Math.floor(scrollTop / itemSize) * itemSize;

这里的滚动的项数实际上就是startIndex,因此上述代码可以进一步简化为:

const offset = startIndex * itemSize;

后记

可以发现,当快速滚动时,下方内容可能会出现显示不全的情况。下一篇我们将为虚拟列表添加缓冲区功能,期待与各位道友再次相见!

源码

github.com/zm8/wechat-…

最后

点赞👍 + 关注➕ + 收藏❤️ = 学会了🎉。

更多优质内容关注公众号,@前端大卫

源码完整注释

JS 如下:

// 定义组件的 props
const props = defineProps<{
  listData: { value: T; uid: U }[]; // 列表数据,每项包含值和唯一标识符
  itemSize: number; // 每项的高度
}>();

// 屏幕高度(用于计算可视区域的显示数量)
const screenHeight = ref(0);
// 当前滚动距离(顶部到当前可视区域顶部的距离)
const scrollTop = ref(0);
// 列表容器的引用,用于获取容器的实际高度
const containerRef = ref<HTMLElement | null>(null);

// 列表总数量
const totalItemCount = computed(() => props.listData.length);

// 列表容器的总高度,计算方法为:总数量 * 每项高度
const containerHeight = computed(() => totalItemCount.value * props.itemSize);

// 可视区域显示的数量,取决于屏幕高度和每项的高度
const visibleCount = computed(() =>
  Math.ceil(screenHeight.value / props.itemSize)
);

// 当前滚动位置对应的起始索引
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemSize));

// 当前滚动位置对应的结束索引,基于起始索引和可视数量
const endIndex = computed(() => startIndex.value + visibleCount.value);

// 当前需要渲染的列表项,基于起始索引和结束索引
const renderedItems = computed(() =>
  props.listData.slice(startIndex.value, endIndex.value)
);

// 偏移位置,用于调整显示位置,使列表与滚动位置对齐
const offset = computed(() => startIndex.value * props.itemSize);

// 滚动事件处理函数,更新当前滚动位置
const handleScroll = (e: Event) => {
  scrollTop.value = (e.target as HTMLElement).scrollTop;
};

// 组件挂载后,初始化屏幕高度
onMounted(() => {
  screenHeight.value = containerRef.value?.clientHeight ?? 0;
});

HTML 如下:

<template>
  <!-- 列表容器,监听滚动事件 -->
  <div class="infinite-container" ref="containerRef" @scroll="handleScroll">
    <!-- 占位区域,用于模拟完整列表的高度 -->
    <div
      class="infinite-placeholder"
      :style="{ height: `${containerHeight}px` }"
    ></div>
    <!-- 实际渲染的内容,根据偏移量动态调整位置 -->
    <div :style="{ transform: `translate3D(0, ${offset}px, 0)` }">
      <!-- 渲染的列表项 -->
      <div
        class="infinite-item"
        v-for="item in renderedItems"
        :key="item.uid"
        :style="{ height: `${props.itemSize}px` }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>

CSS 如下:

/* 列表容器样式 */
.infinite-container {
  border: 1px solid red; /* 红色边框,用于调试 */
  position: relative; /* 相对定位 */
  overflow: auto; /* 可滚动 */
  height: 100%; /* 占满父容器高度 */
}

/* 占位元素样式,用于模拟完整列表高度 */
.infinite-placeholder {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1; /* 放在内容后面 */
}

/* 列表项样式 */
.infinite-item {
  border-bottom: 1px solid blue; /* 每项底部的蓝色边框 */
}