大家好,我是 前端架构师 - 大卫。
更多优质内容请关注微信公众号 @程序员大卫。
初心为助前端人🚀,进阶路上共星辰✨,
您的点赞👍与关注❤️,是我笔耕不辍的灯💡。
今天,我将用最通俗易懂的语言,与大家分享一项常见的前端优化技术。—— 虚拟列表
。
在线地址预览: codesandbox.io/p/devbox/si…
这篇文章主要帮助大家理解 虚拟列表 的
核心原理
。后续我会再写一篇文章,支持“不定高虚拟列表”、动态加载图片、并能动态监听高度!🎉🎉🎉
背景
假设我们需要渲染 100,000 个元素,如果直接渲染所有内容,DOM 节点会过多,占用大量内存,从而导致浏览器性能显著下降,甚至可能崩溃。
核心原理
虚拟列表的核心原理非常简单:只渲染“可视区域”内的 DOM 节点,通过这一方式显著减少 DOM 的数量。
如下图,只渲染 item-99
~item-104
这6个元素。
实现
需要解决的问题
在实现虚拟列表之前,我们需要思考以下几个问题:
- 虚拟列表只保留
可视区域
内的元素,那么如何让滚动条看起来和完整列表一样,保持原有的滚动体验? 可视区域
内最多能显示多少个列表项?- 渲染的列表内容如何确定?
渲染区域
的偏移量如何计算?
接下来我们逐一解决这些问题。
问题 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。根据下图可知:
- 起始索引 = 滚动高度 ÷ 每项高度
- 结束索引 = 起始索引 +
可视区域
内的项目总数
具体代码如下:
// 起始索引
const startIndex = Math.floor(scrollTop / itemSize)
// 结束索引
const endIndex = startIndex + visibleCount;
最终,渲染的列表内容可以通过以下方式获取:
const renderedItems = listData.slice(startIndex, endIndex);
问题 4:如何计算“偏移量”?
偏移量的计算是实现虚拟列表的关键,理解了这个就真正掌握了虚拟列表。
假设:
- 每项高度
itemSize
为 100; - 用户滚动高度
scrollTop
为 340。
普通列表滚动的页面渲染如下图:
而在未设置偏移量的虚拟列表中,页面如下。
为什么列表从item-4
开始,因为虚拟列表仅渲染“可视区域”内的内容。
所以必须设置偏移量
,把内容拉回到可视区域
。设置完偏移量
后,页面渲染如下:
偏移量的计算方式非常简单:它等于滚动的项数乘以每项高度。
const offset = Math.floor(scrollTop / itemSize) * itemSize;
这里的滚动的项数实际上就是startIndex,因此上述代码可以进一步简化为:
const offset = startIndex * itemSize;
后记
可以发现,当快速滚动时,下方内容可能会出现显示不全的情况。下一篇我们将为虚拟列表添加缓冲区功能,期待与各位道友再次相见!
源码
最后
点赞👍 + 关注➕ + 收藏❤️ = 学会了🎉。
更多优质内容关注公众号,
@前端大卫
。
源码完整注释
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; /* 每项底部的蓝色边框 */
}