为什么要用虚拟列表
哈啰好物商城中,存在大量的长列表数据,例如下图列出的商品瀑布流、特卖会场列表等。用户滑动到页面底部,则加载新的数据进来,页面上的DOM节点越来越多,容易导致页面卡顿,交互不流畅。针对这种长列表的场景,我们可以采用虚拟列表来做优化。
什么是虚拟列表
虚拟列表,顾名思义,并不是真实数据列表的一个体现,而是只截取一部分列表数据用于填充可视区域。
以品牌会场页面为例,在屏幕可展示范围内可能只有3条卡片数据,而在屏幕可视范围之外,我们可能已经滑动请求了几百上千条数据,这些数据不被我们看见,却在页面DOM中真实存在。我们完全可以只渲染屏幕中间可以被看见的几张卡片,当用户滚动时,根据滚动距离来计算替换这几张卡片里的数据,来达到模拟真实列表滚动的效果。
如何实现一个虚拟列表
我们先写一个简单的Demo,来模拟页面下拉加载数据的场景, 每页加载20条数据,下拉到底部继续加载20条数据,我们可以在开发者工具中看到,DOM节点在持续的增加。
现在我们来一步步实现一个虚拟列表。假设我们的滚动可视区域高度为1000px,每个列表项高度为100px, 那么可视区域可以渲染出10条数据。当滚动距离为0时,渲染的列表项数据索引是从0到9( 由于我们用数组的slice方法切割数组时,索引是一个左闭右开区间,因此这里做数据切割时是slice(0, 10))。当滚动了100px之后,相当于把第一个列表项滚动上去了,那实际渲染的列表项数据就可以替换成从1到10(slice(0, 11))。
同理可推,当滚动了scrollTop距离之后,相当于把前面(scrollTop / itemHeight)个列表项滚动上去了,那实际渲染的列表项数据就是从(scrollTop / itemHeight) 到 (scrollTop / itemHeight) + 9。
当然,如果滚动的scrollTop不足一个列表项高度,则当前列表项还在可视区域内,不能替换,所以我们使用scrollTop/itemHeight时,要向下取整。
定义以下变量:
- 滚动区域高度记为scrollContainerHeight
- 每一个列表项的高度是固定的,记为itemHeight
- 列表可视范围内的列表项数量,记为visibleCount
- 滚动的距离记为scrollTop
- 完整的列表数据,记为listData
- 实际渲染的列表数据,记为visibleList
- 列表项起始索引,记为startIndex
- 列表项结束索引,记为endIndex
- 列表向下偏移量,记为scrollOffset
得出以下计算公式:
- visibleCount = Math.ceil(scrollContainerHeight / itemHeight)
- startIndex = Math.floor((scrollTop / itemHeight))
- endIndex = startIndex + visibleCount
- visibleList = listData.slice(startIndex, endIndex)
- scrollOffset = startIndex * itemHeight
我们用代码实现看看:
<template>
<div @scroll="scrollEvent($event)" class="list-container">
<div :style="{height: `${totalHeight}px`, transform: `translateY(${scrollOffset}px)`}" class="list-wrapper">
<div v-for="item in visibleList" :key="item" class="list-item">
{{ item }}
</div>
</div>
</div>
</template>
const totalHeight = computed(() => {
return listData.value.length * itemHeight.value;
})
const scrollEvent = (e) => {
const scrollTop = e.target.scrollTop; // 获取滚动距离
startIndex.value = Math.floor(scrollTop / itemHeight.value); // 起始索引为滚动距离/单个列表项高度
endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
visibleList.value = listData.value.slice(startIndex.value, endIndex.value);
scrollOffset.value = startIndex.value * itemHeight.value; // 偏移量为已滑动出去的列表项数量*单个列表项高度
}
看下现在的实现效果,无论列表有多少项,页面中始终只会渲染10个列表DOM。
不定高的虚拟列表
上面的Demo只是讲述了列表项高度固定时的一个最基础的演示,实际业务应用中,我们还经常会遇到列表项高度不固定的场景,例如商品瀑布流。由于列表项高度不同,如果仍然使用上面的方式去计算索引替换数据会不准确,页面会发生抖动。我们可以先设置一个预估的高度,当列表项加载出来后,获取实际渲染的列表项高度,进行更新。
预估列表项每一项的高度为50px,那么我们得到初始的position数组为, 其中index为列表项数据的实际索引,height为该列表卡片的高度,top为该卡片距离顶部的距离,bottom为该卡片底边的位置。
当页面渲染出来后,我们获取到每一项的实际高度,如果实际高度和之前预估的高度不一致,就更新该项的height值。
例如:index为0的列表项实际高度为44px,则height更新为44px,由于高度小了6px,底边的位置也就向上了6px,所以bottom更新为50 - 6 = 44。
假设index为1的列表项实际高度就是我们预估的50,但由于index为0的列表项高度减少,导致index为1的列表项的top和bottom也需要相应的减少6, 由此我们发现,当某一项高度改变后,在这一项之后的所有列表项的top和bottom都会受到影响,我们都要去做一次数据更新。
const updateItemHeight = () => {
const nodes = visibleItemRef.value;
nodes.forEach((node) => {
if (!node) {
return;
}
const rect = node.getBoundingClientRect();
const id = node.id;
const oldHeight = positions.value[id].height; // 获取当前渲染的列表项前一次高度
const currentHeight = rect.height; // 获取当前渲染的列表项当前高度
const diffHeight = oldHeight - currentHeight; // 获取两次高度的差值
if (diffHeight !== 0) {
positions.value[id].height = currentHeight; // 更新这一项的高度
positions.value[id].bottom = positions.value[id].bottom - diffHeight;
}
});
const startId = +nodes[0].id; // 当前渲染的列表项第一项的实际索引
// 由于当前索引的高度有变化,从当前索引往后的所有项的top和bottom都要更新
for(let i = startId+1; i<positions.value.length; i+=1) {
positions.value[i].top = positions.value[i-1].bottom; // 当前项距离顶部的距离就等于上一项底边的位置
positions.value[i].bottom = positions.value[i].top + positions.value[i].height// 当前项底边的位置就等于当前项距离顶部的位置+当前项的卡片高度
}
};
以上图渲染的列表项为例,当滚动距离超过22时,说明index=0的这一张卡片已经被滑出可视区域,此时的startIndex可以替换为1。
在固定高度的情况下,我们的startIndex=滚动距离/单张卡片的高度,即Math.floor(scrollTop / itemHeight.value)。
在不定高度的情况下,我们只需在position数组中,查找到第一个bottom > scrollTop的卡片,记为startIndex, 偏移量修改为startIndex - 1的卡片的bottom值。
const scrollEvent = (e) => {
const scrollTop = e.target.scrollTop; // 获取滚动距离
const startItem = positions.value.find((p) => {
return p.bottom > scrollTop;
});
if (startItem) {
startIndex.value = startItem.index; // 起始索引为第一个bottom值大于scrollTop的
} else {
startIndex.value = 0;
}
endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
visibleList.value = listData.value.slice(
startIndex.value,
endIndex.value
);
if (startIndex.value > 0) {
scrollOffset.value = positions.value[startIndex.value - 1].bottom; // 偏移量为已滑动出去的列表项的底边位置
} else {
scrollOffset.value = 0;
}
};
上述所有代码实现只是从最基本的思路入手实现的简单Demo,在实际实现过程中,我们还可以对虚拟列表的代码做很多的优化。例如结合分页请求、设置缓冲区、计算偏移量的方法用二分查找等方式降低搜索次数、滚动节流。当然,业界已经有很多封装好的库可以直接拿来用,例如vue-virtual-scroller。
商城H5中的实践效果
在商城H5的品牌特卖会场,我们在引入了vue-virtual-scroller的基础上,添加了下拉分页请求,效果如下:
(本文作者:马新新)
本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。