前言
虚拟列表(Virtual List)是什么?这是一种前端性能优化的技术,主要是应用于渲染超长列表的场景。其核心原理是:只渲染可视区域内的列表项,而非全部数据,从而大幅度地减少了DOM节点的数量,提升页面性能。
最近刚好了解到虚拟列表的相关内容,虚拟列表主要有定高和不定高两种情景,其实现方法还挺有意思的。前面研究了定高的虚拟列表的实现方法,即在每次滚动页面时,更新 startIndex 和 endIndex 的值,计算列表的Y轴偏移量 offset ,通过设置样式 { transform: `translateY(${offset}px)` }实现虚拟滚动效果。
本文是关于实现一个不定高的虚拟列表,即列表的每一项是不固定高度的,这种场景在实际中是普遍存在的。
示意图
不定高的虚拟列表的示意图如下所示。
主要分为以下几个部分:
-
外部容器:固定高度(即可视区域的高度),超出隐藏;监听页面的 scroll 滚动事件。
-
占位元素区域:设置的是所有内容的高度,用于撑开滚动区域,其高度会远远大于外部容器的高度以实现滚动的效果。
-
实际列表区域:实际渲染的列表内容是很少的,通过 transform 属性模拟滚动效果。
接下来详细分析其实现原理。
实现原理
首先,页面框架是这样设置的:容器是 .virtual-scroll,占位元素是 .scroll-phantom,实际渲染的列表是 .scroll-content,其中,每一项是<div class="item"></div>。而 totalHeight 是占位元素的总高度,是动态设置的。
<template>
<div class="virtual-scroll" ref="container" @scroll="handleScroll">
<div class="scroll-phantom" :style="{ height: `${totalHeight}px` }"></div>
<div class="scroll-content" :style="{ transform: `translateY(${offset}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: `${item.height}px`}"
:ref="(el) => renderItemsRef(el, item.id)"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<style>
.virtual-scroll {
height: 100%;
overflow-y: auto;
position: relative;
}
.scroll-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.item {
width: 100%;
border: 1px solid #000;
display: flex;
justify-content: center;
align-items: center;
}
</style>
其次,我们生成全部的模拟数据 allData,每项数据的高度是不固定的,用属性 height 表示,是用 Math.random() 随机生成的。可视区域的数据 visibleData 获取的是 allData 的 [startIndex, endIndex] 范围的数据。
再次,总高度 totalHeight,是对于模拟数据 allData 的每一项的高度进行累加得到的。
那么,我们会有以下几个疑问:
1、我们需要考虑的是如何确定 startIndex 和 endIndex 的值?
将 startIndex 初始化为 0,在每次滚动页面时,动态更新。而 endIndex 的值为 startIndex + visibleCount,这里的 visibleCount 设置为 15。
由于每一项的高度不固定,因此我们无法通过 滚动距离/每一项的高度 直接获取 startIndex 的值。在每次滚动时,我们通过二分查找找到第一个超过当前滚动位置的项,也就是 startIndex 的值。使用二分查找的方法更加高效,时间复杂度是 O(nlogn) 而非 O(n)。
2、我们怎么模拟滚动效果?
对于实际渲染的列表 .scroll-content ,通过设置样式{ transform: `translateY(${offset}px)` },offset 就是页面向上滑动的距离,在每次滚动页面时,动态更新即可。这里跟定高的虚拟列表是一样的。
3、怎么获取 offset 的值?
由于我们已经预先计算并缓存所有数据项的累计高度(accumulatedHeights 数组),因此得到 startIndex 的值后,就能直接得到 offset 的值:
offset.value = startIndex > 0 ? accumulatedHeights.value[startIndex - 1] : 0
至此,核心原理就是这样。
理解了上面的几个问题,也就不难掌握不定高的虚拟列表的实现方法了。
完整代码
完整的示例代码如下:
<template>
<div class="virtual-scroll" ref="container" @scroll="handleScroll">
<div class="scroll-phantom" :style="{ height: `${totalHeight}px` }"></div>
<div class="scroll-content" :style="{ transform: `translateY(${offset}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: `${item.height}px`}"
:ref="(el) => renderItemsRef(el, item.id)"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
const visibleCount = 15 // 假设每次渲染 15 条数据
const ITEM_HEIGHT = 100 // 假设每个 item 真实渲染后高度接近 100px
// 假设 10000 条数据
const allData = Array.from({ length: 10000 }, (v, i) => {
return {
id: i,
content: `item-${i+1}`,
height: Math.floor(Math.random() * 100) + 50
}
})
const container = ref(null)
const visibleData = ref([])
const totalHeight = ref(0)
const renderedItemsHeight = ref({})
const offset = ref(0)
// 核心渲染更新函数
const updateRenderItems = () => {
const scrollTop = container.value?.scrollTop
let startIndex = 0
// i. 预先计算并缓存所有项的累计高度
const accumulatedHeights = computed(() => {
let sum = 0
return allData.map(item => {
sum += renderedItemsHeight.value[item.id] || ITEM_HEIGHT
return sum
})
})
// ii. 二分查找找到第一个超过当前滚动位置的项
let left = 0
let right = accumulatedHeights.value.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (accumulatedHeights.value[mid] >= scrollTop) {
right = mid - 1
startIndex = mid
} else {
left = mid + 1
}
}
visibleData.value = allData.slice(startIndex, startIndex + visibleCount)
offset.value = startIndex > 0 ? accumulatedHeights.value[startIndex - 1] : 0
}
// 收集已渲染项的实际高度
const renderItemsRef = (el, id) => {
if (el) {
renderedItemsHeight.value[id] = el.offsetHeight
nextTick(updateTotalHeight)
}
}
// 更新占位的总高度
const updateTotalHeight = () => {
totalHeight.value = allData.reduce((sum, item) => sum + (renderedItemsHeight.value[item.id] || ITEM_HEIGHT), 0)
}
const handleScroll = () => {
updateRenderItems()
}
onMounted(() => {
updateRenderItems()
})
</script>
<style>
.virtual-scroll {
height: 100%;
overflow-y: auto;
position: relative;
}
.scroll-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.item {
width: 100%;
border: 1px solid #000;
display: flex;
justify-content: center;
align-items: center;
}
</style>
后记
简而言之,不定高的虚拟列表是一种前端性能优化的技术,可以应用于渲染超长列表的场景。由于每一项的高度不固定,因此我们无法直接获取 startIndex 的值。我们通过二分查找的方法,能够以 O(nlogn) 的效率找到 startIndex 的值,结果更加高效。
开卷有益!