小火苗变声器
65.51MB · 2026-04-16
大家好,我是elk。
上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List) 。
虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。
核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。
在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:
主要是涉及到事件以及基础数据的计算和更新
通过容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算
起始索引「startIndex」
固定高度
index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」
startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」
动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」
结束索引「endIndex」
index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」
endIndex = Math.min( list.length, index )
固定高度
top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」
动态高度
top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」
在基础知识点上进行的优化措施,提升列表性能,优化用户体验
当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染
在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:
在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame
在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。
以下是一个支持 动态高度、缓冲区、高度缓存、二分查找 的完整虚拟列表组件。
<template>
<div
@scroll="handleScroll"
ref="containerRef"
:style="{ height: `${height}px` }"
class="w-full position-relative top-0 left-0 overflow-auto"
>
<!-- 空状态 -->
<div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
<slot name="empty" />
</div>
<!-- 占位撑高容器 -->
<template v-else>
<div
:style="{ height: `${containerHeight}px` }"
class="w-full position-absolute top-0 left-0"
></div>
<!-- 可视化容器 -->
<div
:style="{ transform: `translateY(${offset}px)` }"
class="w-full position-absolute top-0 left-0"
>
<div
v-for="(item, index) in visibleList"
:key="item.id || index"
ref="itemRef"
:style="{ height: `${itemHeight}px` }"
class="w-full flex items-center justify-center"
>
<slot name="default" :item="item" :index="index + startIndex" />
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'
interface ListItem {
id: number | string
name: string
}
interface PropsParams {
// 列表数据
data: ListItem[]
// 容器高度
height: number
// 项高度-预估高度
itemHeight: number
// 缓冲区数量
bufferCount: number
}
const props: PropsParams = defineProps({
data: {
type: Array as PropType<ListItem[]>,
default: () => [],
required: true,
},
height: {
type: Number,
default: 250,
},
itemHeight: {
type: Number,
default: 50,
},
bufferCount: {
type: Number,
default: 5,
},
})
// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)
// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])
// 可视化容器-开始索引
const startIndex = computed(() => {
const index = getStartIndex(scrollTop.value)
return Math.max(0, index - props.bufferCount)
})
// 可视化容器-结束索引
const endIndex = computed(() => {
const index = startIndex.value + visibleCount.value + props.bufferCount * 2
return Math.min(props.data.length, index)
})
// 撑开容器-高度
const containerHeight = computed(() => {
return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
return Math.ceil(props.height / props.itemHeight)
})
// 可视化容器-渲染列表
const visibleList = computed(() => {
return props.data.slice(startIndex.value, endIndex.value)
})
// 偏移量-计算
const offset = computed(() => {
return prefixSumCache.value[startIndex.value]
})
/**
* @description: 二分法-计算初始索引
* @return {*}
*/
const getStartIndex = (scrollTop: number) => {
let left = 0
let right = prefixSumCache.value.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (prefixSumCache.value[mid] === scrollTop) return mid
if (prefixSumCache.value[mid] > scrollTop) {
right = mid - 1
} else {
left = mid + 1
}
}
return left
}
/**
* @description: 初始化高度
* @return {*}
*/
const initHeight = () => {
try {
// 初始化项高度缓存集合
itemHeightCache.value = props.data.map(() => props.itemHeight)
// 初始化前缀和缓存集合
initPrefixSum()
} catch (error) {
console.error('初始化高度失败:', error)
}
}
/**
* @description: 初始化|修改 前缀和缓存集合
* @return {*}
*/
const initPrefixSum = (index: number = 0) => {
try {
prefixSumCache.value = []
let sum = 0
// 计算前缀和缓存集合,从索引开始计算,直到列表结束
itemHeightCache.value.forEach((item, i) => {
if (i >= index) {
prefixSumCache.value.push(sum)
sum += item
}
})
} catch (error) {
console.error('初始化前缀和缓存集合失败:', error)
}
}
/**
* @description: 修改项的真实高度-当高度发生变化时才更新
* @return {*}
*/
const updateItemHeight = async () => {
try {
await nextTick()
const visibleItems = itemRef.value
if (visibleItems.length === 0) return
let hasHeightChanged = false
visibleItems.forEach((el, index) => {
if (el) {
const itemIndex = index + startIndex.value
const itemHeight = el.clientHeight
// const itemHeight = el.getBoundingClientRect().height
// 只有高度变化的时候才更新缓存
if (itemHeight !== itemHeightCache.value[itemIndex]) {
itemHeightCache.value[itemIndex] = itemHeight
hasHeightChanged = true
}
if (hasHeightChanged) {
initPrefixSum(itemIndex)
}
}
})
} catch (error) {
console.error('更新项目高度失败:', error)
}
}
/**
* @description: 处理滚动事件
* @return {*}
*/
let ticking = false
const handleScroll = () => {
console.log(' ~ handleScroll ~ containerRef: 触发了滚动事件')
if (!ticking) {
requestAnimationFrame(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value?.scrollTop || 0
updateItemHeight()
}
ticking = false
})
ticking = true
}
}
// 数据变化-更新项高度
watchEffect(() => {
if (props.data.length > 0) {
initHeight()
updateItemHeight()
}
})
// 初始化-更新项高度
onMounted(() => {
initHeight()
updateItemHeight()
})
</script>
<style lang="css" scoped></style>
bufferCount(比如从 2 提升到 5)。nextTick 后获取真实高度,并重新计算前缀和。requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。prefixSum 的维护很容易出错,有什么建议?推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0,prefixSum[i] 表示前 i 项的总高度。这样:
prefixSum[i]prefixSum[n]scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。updateRealHeights 重新测量受影响的项。transform 偏移,还有别的方案吗?也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY。
虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。
优化永无止境,如果你还想更进一步,可以探索:
ResizeObserver 每一项的尺寸变化,自动更新高度缓存。IntersectionObserver 实现可视区外图片懒加载。希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点攒、评论、转发~