之前我写了一篇文章:如何使用 IntersectionObserver API 来实现数据的懒加载 在文章的最后,我们提到如果加载的列表数据越来越多,我们不可能把所有的数据都渲染出来,因为这样会导致页面卡住甚至崩溃。
为了优化这种长列表场景,我们可以使用虚拟列表,核心思想是:仅渲染可视区域内(及其附近)的列表项。即不管列表有多少条数据,只取指定数量的项渲染,比如说 15 条,15 条足以覆盖可视区域以及其上下附近区域,当然这个数量视具体情况决定。
定高场景
定高的意思是我们提前知道每个列表项的高度,比如 100px。
假设我们现在有 10000 条数据,当用户上下滚动的时候,始终取对应的 15 条数据渲染。代码以 vue3 来示例。
思路分析
-
定义 DOM 结构和滚动事件
在上述代码中,我们定义三层 DOM 结构,最外层是视口 viewport,次外层是所有列表项的父容器(这里设置其高度为整个列表内容的高度:style=“{ height:${contentHeight}px
}”),最里层是通过 v-for 循环生成的列表项。然后我们在视口层监听 scroll 事件。 -
设置样式
这里视口的高度设置为 800px,列表项 content-item 的高度设置为 100px,且定位设为绝对定位,这很关键,因为上下滚动的时候每个列表项距离顶部的距离需要通过 top 这个定位属性来设置。 -
渲染数据的截取
我们整个列表 items 有 10000 条数据,当用户上下滚动的时候,我们需要知道当前位置需要截取列表中的哪 15 条数据?所以我们需要知道截取开始的位置 startIndex,startIndex 是动态变化的,它需要根据用户的滚动位置来计算。scrollTop 属性可以获取滚动条距离内容顶部的距离 scrollTop,scrollTop 是由 content-item 的高度撑起来的,那么 startIndex = scrollTop/100px(content-item) ,那么我们当前需要渲染的数据就是:renderItems = items.slice(startIndex, startIndex+15)
具体代码如下:
在上述代码中,我们在使用 Array.form 方法生成列表数据的时候,每个 item 都设置了 top 属性,top = i * ITEM_HEIGHT,即第一个 item 距离顶部的距离为 0,第二个 item 距离顶部的距离为 100px,第三个 item 距离顶部的距离为 200px……,在 DOM 上设置 style::style=“{ top :${item.top}px
}” 可保证每个 item 处于正确的位置。
运行代码,上下滚动,可得到如下表现:
虚拟列表定高
在视频中我们可以看到,无论我们怎么滑动列表,最终都只会渲染 15 个列表项。
完整代码如下:
<template>
<div
ref="viewportRef"
class="viewport"
@scroll="handleScroll"
>
<div
class="content-wrap"
:style="{ height: `${contentHeight}px` }"
>
<div
v-for="item in renderItems"
:key="item.id"
:style="{ top : `${item.top}px`}"
class="content-item"
>
<p>{{ item.text }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const ITEM_HEIGHT = 100; // 假设每个 item 的高度为 100px
const RENDER_SIZE = 15; // 假设每次渲染 15 条数据
// 假设 10000 条数据
const items = Array.from({ length: 10000 }, (v, i) => {
return {
id: i,
text: `item-${i+1}`,
top: i * ITEM_HEIGHT
};
});
const startIndex = ref(0);
const viewportRef = ref(null);
const renderItems = computed(() => {
const endIndex = startIndex.value + RENDER_SIZE;
return items.slice(startIndex.value, endIndex);
});
// 整个列表的高度
const contentHeight = computed(() => {
return items.length * ITEM_HEIGHT;
});
const handleScroll = () => {
const scrollTop = viewportRef.value?.scrollTop;
// 更新 startIndex
startIndex.value = Math.floor(scrollTop / ITEM_HEIGHT);
}
</script>
<style>
.viewport {
height: 800px;
overflow-y: auto;
}
.content-wrap {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content-item {
height: 100px; /* 假设每个项目高度为100px */
position: absolute;
width: 100%;
border: 1px solid #000;
display: flex;
justify-content: center;
align-items: center;
}
</style>
不定高场景
不定高是我们不能提前知道每个列表项的高度,它的高度是动态变化的,具体多高由它的数据的多少决定,有些项数据比较多,那么它最终渲染出来的 DOM 高度就比较高。这种情况就需要我们动态去计算每个列表项的高度。
思路分析
-
定义 DOM 结构和滚动事件
这里最外层视口层的结构和之前定高的情况是一样的,次外层有两个 DOM:一个是 content-placeholder ,用于占位,它的高度就是整个列表渲染后的高度。另一个是 content-wrap ,是当前渲染列表的父容器,注意它的样式::style=“{ transform:translateY(${offset}px)
}”,offset 的值是动态计算的,为的是确保在滚动的过程中,当前渲染的列表处于整个列表中正确的位置。最里层是当前渲染的列表,注意,我们这里的 ref 使用了函数 :ref=“(el) => renderItemsRef(el, item.id)”, renderItemsRef 函数中把已渲染的列表项真实的高度存下来,用于后续的计算。
-
设置样式
占位元素是绝对定位的 -
渲染数据的截取
不定高的情况下要确认 startIndex 要比定高的情况复杂得多。
同定高的情况一下,我同样需要 scrollTop 来用于 startIndex 的计算,观察下图:
观察上图得知,当前视口渲染的第一个列表项 curList1 在整个列表中的位置即为 startIndex,我们可以根据滚动条的位置(scrollTop)来计算,也就是说,我们需要确定在 curList1 之前有几个列表项,我们从索引 0 开始遍历整个列表,把每个列表项的高度相加,当 totalHeight >= scrollTop 的时候,我们就遍历到了 curList1 这个列表项,那么当前的索引 index 就是我们需要的 startIndex,具体代码如下:
上述代码中,allItems 是整个列表数据,具体如下:
allItems 这里我们设置了一个随机高度,模拟不定高的情况,但在实际场景中,height 应该设置为一个接近于 item 渲染后的真实高度,即这里的 ITEM_HEIGHT。同时 hasRenderedItemsHeight 就是已经渲染的列表项的高度的集合,它是一个对象,key 是列表项,value 是列表项渲染后的高度,具体的赋值代码如下,也就是我们前面提到的 renderItemsRef 函数:
在上述代码中,每次有新的列表项渲染完成,我们都需要调用 updateRenderTotalHeight 函数去更新整个列表的实际高度。最后,我们在组件挂载的时候和滚动事件触发的时候调用 updateRenderItems 即可实现不定高的虚拟列表
运行代码,可得到如下表现:
虚拟列表不定高
完整代码如下:
<template>
<div
ref="viewportRef"
class="viewport"
@scroll="handleScroll"
>
<div class="content-placeholder" :style="{ height: `${renderTotalHeight}px` }"></div>
<div
class="content-wrap"
:style="{ transform: `translateY(${offset}px)` }"
>
<div
v-for="item in renderItems"
:ref="(el) => renderItemsRef(el, item.id)"
:key="item.id"
:style="{ height: `${item.height}px`}"
class="content-item"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
const RENDER_SIZE = 15; // 假设每次渲染 15 条数据
const ITEM_HEIGHT = 100; // 假设每个 item 真实渲染后高度接近 100px
// 假设 10000 条数据
const allItems = Array.from({ length: 10000 }, (v, i) => {
return {
id: i,
text: `item-${i+1}`,
// 设置随机高度, 在实际项目中应该根据 item 的实际情况设置一个接近于 item 渲染后的高度
height: Math.floor(Math.random() * 100) + 50
};
});
const viewportRef = ref(null);
const renderItems = ref([]); // 当前需要渲染的 item
const renderTotalHeight = ref(0); // 整个已渲染列表的高度
const hasRenderedItemsHeight = ref({}); // 已渲染的 item 数据 height
const offset = ref(0);
const updateRenderItems = () => {
const scrollTop = viewportRef.value?.scrollTop;
let startIndex = 0;
let startOffset = 0;
for (let i = 0; i < allItems.length; i++) {
const h = hasRenderedItemsHeight.value[allItems[i].id] || ITEM_HEIGHT;
startOffset += h;
if (startOffset >= scrollTop) {
startIndex = i;
break;
}
}
renderItems.value = allItems.slice(startIndex, startIndex + RENDER_SIZE);
offset.value = startOffset - hasRenderedItemsHeight.value[allItems[startIndex].id];
}
const renderItemsRef = (el, id) => {
if (el) {
// 存放已渲染的 item 的高度
hasRenderedItemsHeight.value[id] = el.offsetHeight;
// 更新容器的高度
nextTick(updateRenderTotalHeight);
}
}
const updateRenderTotalHeight = () => {
renderTotalHeight.value = allItems.reduce((sum, item) => sum + (hasRenderedItemsHeight[item.id] || ITEM_HEIGHT), 0);
}
const handleScroll = () => {
updateRenderItems();
}
onMounted(() => {
updateRenderItems();
})
</script>
<style>
.viewport {
height: 800px;
overflow-y: auto;
position: relative;
}
.content-placeholder {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.content-item {
width: 100%;
border: 1px solid #000;
display: flex;
justify-content: center;
align-items: center;
}
</style>