无限虚拟滚动组件:优化长列表展示的利器

1,273 阅读10分钟

在如今的互联网应用中,长列表数据展示随处可见。比如电商平台的商品列表、社交媒体的动态流等。但如果处理不当,大量数据渲染会导致页面卡顿、加载缓慢,严重影响用户体验。无限虚拟滚动组件应运而生,它能有效解决这些问题。本文将详细介绍一个无限虚拟滚动组件,帮助你理解其原理与使用方法。

无限虚拟滚动组件介绍

无限虚拟滚动组件的核心原理是只渲染当前可见区域及附近的数据项,而非一次性渲染整个长列表。当用户滚动时,组件动态替换已离开可见区域的数据项为新的数据项,从而极大减少内存占用和渲染开销,提升页面性能。

(一)关键功能

  1. 无限滚动加载:当用户滚动到列表底部时,自动触发加载更多数据,实现无缝滚动体验。
  2. 虚拟列表渲染:根据滚动位置动态计算并渲染可见区域的数据项,节省性能。
  3. 固定高度与不定高度支持:既能处理列表项高度固定的情况,也能适应高度不固定的列表项。

(二)代码结构概览

PS:这个版本是业务组件,基于element-plus封装的,主要使用了v-infinite-scroll实现触底加载,如果您在使用时没有使用element-plus,可以自行实现监听触底事件。

组件Props

属性名类型默认值说明
heightnumber300无限滚动容器的高度,单位为像素。
onScroll(params: CommonParams) => Promise<CommonResponse<T>>() => Promise.resolve({ total: 0, list: [] })滚动时触发的加载数据方法,返回一个包含数据总数和列表项的 Promise。
delaynumber200滚动触发加载更多的延迟时间,单位为毫秒。
distancenumber0滚动到距离底部多远时触发加载更多,单位为像素。
disabledbooleanfalse用于禁用无限滚动功能。
immediatebooleantrue表示是否在组件挂载后立即触发 onScroll
方法。
loadingTextHTMLElement 、 Component 、 string'Loading...'加载更多时显示的文本或组件。
keyFieldstring'id'用于列表项的唯一标识字段。
pageSizenumber10每页加载的数据量。
itemHeightnumber50isFixedHeighttrue时是列表项的实际高度,为false时是列表项的预设高度。
bufferSizenumber10虚拟列表的缓冲区大小,用于提前渲染部分不可见的列表项。
isFixedHeightbooleantrue表示列表项是否为固定高度。
keyFiledstring'id'

模版内容

<el-scrollbar :height="props.height">
        <ul class="infinite-virtual-list" ref="scrollableRef" v-infinite-scroll="onScroll"
            :infinite-scroll-disabled="props.disabled" :infinite-scroll-delay="props.delay"
            :infinite-scroll-distance="props.distance" :infinite-scroll-immediate="props.immediate"
            :style="{ height: props.height + 'px', overflow: 'auto' }" @scroll="updateVisibleItems">
            <div class="virtual-container" :style="{ height: totalHeight + 'px' }">
                <ul class="virtual-list" :style="{ transform: `translateY(${translateY}px)` }">
                    <template v-if="visibleItems.length > 0">
                        <li v-for="(visibleItem, index) in visibleItems" :key="visibleItem[props.keyFiled]">
                            <template v-if="props.isFixedHeight">
                                <div class="virtual-list-item" :style="{ height: props.itemHeight + 'px' }">
                                    <slot :item="visibleItem" :key="visibleItem[props.keyFiled]"></slot>
                                </div>
                            </template>
                            <template v-else>
                                <ListItem :id='visibleItem[props.keyField]' :itemKey="visibleItem[props.keyField]"
                                    :itemHeightInfo="visibleItemHeights[index]" @heightChange="handleItemHeightChange">
                                    <slot :item="visibleItem" :key="visibleItem[props.keyField]"></slot>
                                </ListItem>
                            </template>
                        </li>
                    </template>
                    <template v-else-if="!loading && totalLength === 0">
                        <slot name="empty">
                            <el-empty description="暂无数据" :image-size="100"></el-empty>
                        </slot>
                    </template>
                    <li v-if="loading && hasMore" class="loading-text">
                        <component :is="props.loadingText" v-if="isComponent" />
                        <span v-else>{{ props.loadingText }}</span>
                    </li>
                </ul>
            </div>
        </ul>
    </el-scrollbar>
  • el-scrollbar:提供滚动条功能,设置了高度和滚动相关的属性。
  • v-infinite-scroll="onScroll":当滚动触发一定条件时,调用 onScroll 方法,用于加载更多数据。
  • @scroll="updateVisibleItems":监听滚动事件,触发 updateVisibleItems 方法,用于更新可见列表项。
  • v-if="visibleItems.length > 0":根据可见列表项的数量判断是否显示列表项。
  • v-if="props.isFixedHeight":根据 isFixedHeight 属性决定是否采用固定高度的列表项样式。
  • :style="{ transform: translateY(${translateY}px) }:通过 translateY 的值来垂直移动列表,实现滚动效果。
  • slot:允许插入自定义的列表项内容。
  • @heightChange="handleItemHeightChange":当列表项高度变化时,调用 handleItemHeightChange 方法,主要用于不定高列表项的处理。

脚本部分

核心内容就三个,计算startIndex、endIndex用于更新可见列表项处理子元素的实际高度计算偏移量,更新列表项

// 计算定高时的startIndex和endIndex
const startIndex = Math.floor(scrollTop / props.itemHeight);
const endIndex = startIndex + Math.floor(scrollable.clientHeight / props.itemHeight) + props.bufferSize;

// 计算不定高时的startIndex
const calculateStartIndex = (scrollTop: number) => {
    const heights = allItemHeights.value;
    let cumulativeHeight = 0;
    for (let i = 0; i < heights.length; i++) {
        cumulativeHeight += heights[i].height;
        if (cumulativeHeight >= scrollTop) {
            return i;
        }
    }
    return heights.length - 1;
};

// 计算不定高时的endIndex
const calculateEndIndex = (startIndex: number, clientHeight: number) => {
    const heights = allItemHeights.value;
    let visibleHeight = 0;
    let i = startIndex;
    while (visibleHeight < clientHeight && i < heights.length) {
        visibleHeight += heights[i].height;
        i++;
    }
    return startIndex + Math.floor(visibleHeight / (heights[startIndex].height || props.itemHeight)) + props.bufferSize;
};

计算 startIndex 和 endIndex 的目的是为了确定当前可见区域的列表项范围,对于固定高度列表项可以通过简单的除法和取整运算得到,对于不定高度列表项则需要根据每个列表项的实际高度进行累积计算,同时都考虑了缓冲区域,以便提前加载部分数据,让用户在滚动时可以更流畅地查看列表,避免滚动到边界时才开始加载新数据而导致的延迟感。

<template>
    <div ref="itemRef">
        <slot></slot>
    </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

interface ListItemProps {
    itemHeightInfo: { isActualHeight: boolean; height: number };
    itemKey: string | number;
}

const props = defineProps<ListItemProps>();
const emit = defineEmits(['heightChange']);
const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

onMounted(() => {
    if (itemRef.value && !props.itemHeightInfo.isActualHeight) {
        resizeObserver = new ResizeObserver((entries) => {
            entries.forEach((entry) => {
                const height = entry.contentRect.height;
                emit('heightChange', props.itemKey, height);
                if (resizeObserver) {
                    resizeObserver.disconnect();
                    resizeObserver = null;
                }
            });
        });
        resizeObserver.observe(itemRef.value);
    }
});

onUnmounted(() => {
    if (resizeObserver) {
        resizeObserver.disconnect();
        resizeObserver = null;
    }
});
</script>
  1. 创建 ResizeObserver 对象
    • 在组件挂载时(onMounted 钩子函数),会创建一个 ResizeObserver 实例,并将其赋值给 resizeObserver 变量。
    • 该实例接收一个回调函数,当被观察的元素(通过 itemRef 引用)大小发生变化时,该回调函数会被调用。
  2. 观察元素
    • 使用 resizeObserver.observe(itemRef.value) 开始观察 itemRef 所引用的元素。
  3. 处理元素大小变化
    • 当观察的元素大小发生变化时,ResizeObserver 的回调函数会被触发。
    • 在回调函数中,通过 entry.contentRect.height 获取元素的实际高度。
    • 调用 emit('heightChange', props.itemKey, height) 发出一个 heightChange 事件,将元素的 itemKey 和新的高度传递出去,通知父组件该元素的高度发生了变化。
  4. 停止观察
    • 一旦获取到元素的高度,会调用 resizeObserver.disconnect() 停止观察,避免不必要的性能开销。
// 处理列表项高度变化的函数
const handleItemHeightChange = (key: string, height: number) => {
    const index = dataList.value.findIndex(item => item[props.keyFiled] === key);
    if (index !== -1) {
        const currentHeight = allItemHeights.value[index].height;
        if (currentHeight === undefined || currentHeight !== height) {
            allItemHeights.value[index].height = height;
            allItemHeights.value[index].isActualHeight = true;
            updateVisibleItems();
        }
    }
};

在性能优化方面,对于不定高的列表项,通过获取并存储实际高度,可以避免持续使用 ResizeObserver 观察元素。因为 ResizeObserver 虽然可以很好地监测元素的大小变化,但持续观察元素大小会带来一定的性能开销。当获取到元素的实际高度后,就可以将其存储在 allItemHeights 数组中,并且标记为 isActualHeight: true,后续就不需要再使用 ResizeObserver 对该元素进行观察,节省了性能。

// 更新可见列表项的函数
const updateVisibleItems = () => {
    if (totalLength.value === 0) return
    const scrollable = scrollableRef.value;
    if (!scrollable) return;
    const scrollTop = scrollable.scrollTop;
    if (props.isFixedHeight) {
        const startIndex = Math.floor(scrollTop / props.itemHeight);
        const endIndex = startIndex + Math.floor(scrollable.clientHeight / props.itemHeight) + props.bufferSize;
        const adjustedEndIndex = Math.min(endIndex, dataList.value.length);
        visibleItems.value = dataList.value.slice(startIndex, adjustedEndIndex);
        visibleItemHeights.value = allItemHeights.value.slice(startIndex, adjustedEndIndex);
        // 根据滚动位置计算translateY
        translateY.value = startIndex * props.itemHeight;
    } else {
        const startIndex = calculateStartIndex(scrollTop);
        const endIndex = calculateEndIndex(startIndex, scrollable.clientHeight);
        const adjustedEndIndex = Math.min(endIndex, dataList.value.length);
        visibleItems.value = dataList.value.slice(startIndex, adjustedEndIndex);
        visibleItemHeights.value = allItemHeights.value.slice(startIndex, adjustedEndIndex);
        // 根据滚动位置计算translateY
        translateY.value = allItemHeights.value.slice(0, startIndex).reduce((acc, { height }) => acc + height, 0);
    }
};

更新可见列表项和可见列表项的高度信息:
visibleItems.value = dataList.value.slice(startIndex, adjustedEndIndex);,使用 slice 方法从 dataList 中截取从 startIndex 到 adjustedEndIndex 的元素作为新的可见列表项。
visibleItemHeights.value = allItemHeights.value.slice(startIndex, adjustedEndIndex);,同样从 allItemHeights 中截取相应范围的元素作为可见列表项的高度信息。

计算偏移量(translateY):
translateY.value = startIndex * props.itemHeight;:根据起始索引和固定的列表项高度计算偏移量 translateY。这里的偏移量用于在 CSS 中对列表进行垂直位移,以实现滚动效果。将 startIndex 乘以 props.itemHeight 得到的结果赋值给 translateY,使得列表在滚动时显示正确的部分。

完整代码

<template>
    <el-scrollbar :height="props.height">
        <ul class="infinite-virtual-list" ref="scrollableRef" v-infinite-scroll="onScroll"
            :infinite-scroll-disabled="props.disabled" :infinite-scroll-delay="props.delay"
            :infinite-scroll-distance="props.distance" :infinite-scroll-immediate="props.immediate"
            :style="{ height: props.height + 'px', overflow: 'auto' }" @scroll="updateVisibleItems">
            <div class="virtual-container" :style="{ height: totalHeight + 'px' }">
                <ul class="virtual-list" :style="{ transform: `translateY(${translateY}px)` }">
                    <template v-if="visibleItems.length > 0">
                        <li v-for="(visibleItem, index) in visibleItems" :key="visibleItem[props.keyFiled]">
                            <template v-if="props.isFixedHeight">
                                <div class="virtual-list-item" :style="{ height: props.itemHeight + 'px' }">
                                    <slot :item="visibleItem" :key="visibleItem[props.keyFiled]"></slot>
                                </div>
                            </template>
                            <template v-else>
                                <ListItem :id='visibleItem[props.keyField]' :itemKey="visibleItem[props.keyField]"
                                    :itemHeightInfo="visibleItemHeights[index]" @heightChange="handleItemHeightChange">
                                    <slot :item="visibleItem" :key="visibleItem[props.keyField]"></slot>
                                </ListItem>
                            </template>
                        </li>
                    </template>
                    <template v-else-if="!loading && totalLength === 0">
                        <slot name="empty">
                            <el-empty description="暂无数据" :image-size="100"></el-empty>
                        </slot>
                    </template>
                    <li v-if="loading && hasMore" class="loading-text">
                        <component :is="props.loadingText" v-if="isComponent" />
                        <span v-else>{{ props.loadingText }}</span>
                    </li>
                </ul>
            </div>
        </ul>
    </el-scrollbar>
</template>

<script setup lang="ts">
import { defineProps, ref, onMounted, computed, withDefaults } from 'vue';
import ListItem from './components/ListItem.vue';
import {
    CommonParams,
    infiniteScrollDefaultProps,
    virtualScrollDefaultProps,
    CombinedProps
} from '../types';

// 定义组件接收的属性
const props = withDefaults(defineProps<CombinedProps<any>>(), {
    ...infiniteScrollDefaultProps,
    ...virtualScrollDefaultProps
});
// 用于获取滚动元素的ref
const scrollableRef = ref<HTMLUListElement>();
// 计算列表总高度
const totalHeight = computed(() => {
    return props.isFixedHeight
        ? dataList.value.length * props.itemHeight
        : allItemHeights.value.reduce((acc, { height }) => acc + height, 0);
});

// 用于存储虚拟列表的translateY值
const translateY = ref(0);
// 存储每个列表项的实际高度和是否为实际高度的信息
const allItemHeights = ref<{ isActualHeight: boolean; height: number }[]>([]);
// 定义数据列表,用于存储从onScroll方法获取到的列表项
const dataList = ref<any[]>([]);
// 存储当前可见的列表项
const visibleItems = ref<any[]>([]);
// 存储与visibleItems对应的item高度元素
const visibleItemHeights = ref<{ isActualHeight: boolean; height: number }[]>([]);
// 定义数据总数,用于判断是否还有更多数据
const totalLength = ref(0);
// 定义加载状态,用于表示当前是否正在加载数据
const loading = ref(false);
// 定义分页数据,包含当前页码和每页的数据量
const pageData: CommonParams = {
    pageSize: props.pageSize,
    pageNum: 1
};
// 是否还有更多数据可供加载,默认为true
const hasMore = ref(true);
// 计算loadingText是否为组件
// isComponent用于判断loadingText是否为一个组件类型
const isComponent = computed(() => typeof props.loadingText === 'object' && props.loadingText !== null);
// idCounter
let idCounter = 1;

// 计算不定高时的startIndex
const calculateStartIndex = (scrollTop: number) => {
    const heights = allItemHeights.value;
    let cumulativeHeight = 0;
    for (let i = 0; i < heights.length; i++) {
        cumulativeHeight += heights[i].height;
        if (cumulativeHeight >= scrollTop) {
            return i;
        }
    }
    return heights.length - 1;
};

// 计算不定高时的endIndex
const calculateEndIndex = (startIndex: number, clientHeight: number) => {
    const heights = allItemHeights.value;
    let visibleHeight = 0;
    let i = startIndex;
    while (visibleHeight < clientHeight && i < heights.length) {
        visibleHeight += heights[i].height;
        i++;
    }
    return startIndex + Math.floor(visibleHeight / (heights[startIndex].height || props.itemHeight)) + props.bufferSize;
};

// 更新可见列表项的函数
const updateVisibleItems = () => {
    if (totalLength.value === 0) return
    const scrollable = scrollableRef.value;
    if (!scrollable) return;
    const scrollTop = scrollable.scrollTop;
    if (props.isFixedHeight) {
        const startIndex = Math.floor(scrollTop / props.itemHeight);
        const endIndex = startIndex + Math.floor(scrollable.clientHeight / props.itemHeight) + props.bufferSize;
        const adjustedEndIndex = Math.min(endIndex, dataList.value.length);
        visibleItems.value = dataList.value.slice(startIndex, adjustedEndIndex);
        visibleItemHeights.value = allItemHeights.value.slice(startIndex, adjustedEndIndex);
        // 根据滚动位置计算translateY
        translateY.value = startIndex * props.itemHeight;
    } else {
        const startIndex = calculateStartIndex(scrollTop);
        const endIndex = calculateEndIndex(startIndex, scrollable.clientHeight);
        const adjustedEndIndex = Math.min(endIndex, dataList.value.length);
        visibleItems.value = dataList.value.slice(startIndex, adjustedEndIndex);
        visibleItemHeights.value = allItemHeights.value.slice(startIndex, adjustedEndIndex);
        // 根据滚动位置计算translateY
        translateY.value = allItemHeights.value.slice(0, startIndex).reduce((acc, { height }) => acc + height, 0);
    }
};

// 处理列表项高度变化的函数
const handleItemHeightChange = (key: string, height: number) => {
    const index = dataList.value.findIndex(item => item[props.keyFiled] === key);
    if (index !== -1) {
        const currentHeight = allItemHeights.value[index].height;
        if (currentHeight === undefined || currentHeight !== height) {
            allItemHeights.value[index].height = height;
            allItemHeights.value[index].isActualHeight = true;
            updateVisibleItems();
        }
    }
};

// 滚动时加载更多数据的方法
// onScroll方法在滚动条件满足时被触发,用于加载更多数据
// 首先检查是否正在加载或没有更多数据,如果是则返回
// 然后设置加载状态为true,尝试调用props.onScroll方法获取数据
// 更新页码、数据总数和数据列表,并在最后将加载状态设置为false
const onScroll = async () => {
    if (loading.value || !hasMore.value) return;
    loading.value = true;
    try {
        const response = await props.onScroll?.(pageData);
        if (response.total == 0) {
            hasMore.value = false;
            return;
        };
        pageData.pageNum++;
        totalLength.value = response?.total ?? 0;
        const newList = response?.list.map((item) => {
            if (typeof item === 'object' && item !== null) {
                if (!Object.prototype.hasOwnProperty.call(item, props.keyFiled)) {
                    item[props.keyFiled] = idCounter++;
                }
            } else {
                item = { [props.keyFiled]: idCounter++, value: item };
            }
            return item;
        }) ?? [];
        dataList.value = dataList.value.concat(newList);
        if (props.isFixedHeight) {
            newList.forEach(() => {
                allItemHeights.value.push({
                    isActualHeight: true,
                    height: props.itemHeight
                });
            });
        } else {
            newList.forEach(() => {
                allItemHeights.value.push({
                    isActualHeight: false,
                    height: props.itemHeight
                });
            });
        }
        hasMore.value = totalLength.value > dataList.value.length;
        // 数据加载后调用updateVisibleItems
        if (dataList.value.length > 0) {
            updateVisibleItems();
        }
    } finally {
        loading.value = false;
    }
};

// 重置滚动的方法
// resetScroll 方法用于重置分页数据并重新触发 onScroll 方法,以重新加载数据
const resetScroll = async () => {
    pageData.pageNum = 1;
    loading.value = false;
    hasMore.value = true;
    translateY.value = 0;
    totalLength.value = 0;
    dataList.value = [];
    allItemHeights.value = [];
    visibleItems.value = [];
    visibleItemHeights.value = [];
    idCounter = 1
    await onScroll();
};

// 组件挂载后,只有当有数据时才调用updateVisibleItems
onMounted(() => {
    if (props.immediate) {
        onScroll();
    }
});

// 暴露组件内部的数据和方法,供外部组件使用
// dataList 可用于外部访问当前加载的数据列表
// resetScroll 可用于外部触发重置滚动和重新加载数据的操作
defineExpose({
    dataList,
    resetScroll
});
</script>

<style scoped>
.infinite-virtual-list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.virtual-list {
    position: relative;
    list-style-type: none;
}

.virtual-list-item {
    box-sizing: border-box;
    overflow: hidden;
}

.virtual-container {
    position: relative;
}

.loading-text {
    text-align: center;
    color: #999;
}
</style>
<template>
    <div ref="itemRef" class="virtual-list-item">
        <slot></slot>
    </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

interface ListItemProps {
    itemHeightInfo: { isActualHeight: boolean; height: number };
    itemKey: string | number;
}

const props = defineProps<ListItemProps>();
const emit = defineEmits(['heightChange']);
const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

onMounted(() => {
    if (itemRef.value && !props.itemHeightInfo.isActualHeight) {
        resizeObserver = new ResizeObserver((entries) => {
            entries.forEach((entry) => {
                const height = entry.contentRect.height;
                emit('heightChange', props.itemKey, height);
                if (resizeObserver) {
                    resizeObserver.disconnect();
                    resizeObserver = null;
                }
            });
        });
        resizeObserver.observe(itemRef.value);
    }
});

onUnmounted(() => {
    if (resizeObserver) {
        resizeObserver.disconnect();
        resizeObserver = null;
    }
});
</script>

使用示例

<template>
  <el-card>
    <el-button type="primary" style="margin-bottom: 20px;" @click="handelClick">重置</el-button>
    <InfiniteVirtualScroll ref="scrollRef" :onScroll="loadMoreData" :bufferSize="10" :scrollHeight="400"
      :isFixedHeight="false">
      <template #default="{ item, key }">
        <div class="item">
          {{ 'key:' + key + '-----' + item?.value }}
        </div>
      </template>
    </InfiniteVirtualScroll>
  </el-card>
</template>

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { InfiniteVirtualScroll } from '../../../src/components';


const scrollRef = useTemplateRef('scrollRef')
const handelClick = () => {
  scrollRef.value?.resetScroll()
}
// 生成随机字符的函数
const generateRandomString = () => {
  const minLength = 100;
  const maxLength = 500;
  const length = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += charset.charAt(Math.floor(Math.random() * charset.length));
  }
  return result;
};

// 模拟加载更多数据的函数
const loadMoreData = async ({ pageNum, pageSize }) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 创建一个包含1000个元素的数组作为列表数据
      const largeData = Array.from({ length: pageSize }, (_, i) => 'Item' + String(pageSize * pageNum + i + 1) + ' ' + generateRandomString());
      resolve({ total: 10000, list: largeData });
    }, 1000);
  });
};

</script>
<style scoped>
.item {
  padding: 10px;
  border-bottom: 1px solid #e4e7ed;
}
</style>

总结

  • 优点
    • 性能优化:通过虚拟滚动技术,显著提升了长列表的渲染性能,避免了大量数据渲染造成的性能瓶颈。
    • 灵活性:支持固定高度和不定高列表项,适应不同的列表项布局需求。
    • 易于使用:提供了方便的属性和方法,如 resetScroll 可重置滚动状态,onScroll 方便进行数据加载。
    • 可扩展性:可根据具体需求自定义列表项的展示和数据加载逻辑。
  • 适用场景
    • 适用于任何需要展示长列表数据的 Vue 应用,例如产品列表、消息列表、文章列表等,特别是数据量较大的情况,能够提供流畅的用户滚动体验。