前言
大家好,我是elk。
上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 ------ 虚拟列表(Virtual List) 。
什么是虚拟列表?
虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。
核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。
为什么需要虚拟列表?
在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:
- DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
- 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。
适用业务场景
- 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
- 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
- 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。
核心原理
- 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
- 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
- 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
- DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销
核心基础概念
- 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
- 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
- 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
- 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
- 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)
核心知识点
主要是涉及到事件监听以及基础数据的计算和更新
基础知识点
滚动事件监听
通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算
避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能
尺寸计算
- 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
- 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
- 固定高度:无需计算,自行设置的高度「itemHeight」
- 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中
索引计算
起始索引「startIndex」
固定高度
ini
index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」
startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」
动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」
结束索引「endIndex」
ini
index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」
endIndex = Math.min( list.length, index )
偏移量计算
固定高度
ini
top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」
动态高度
css
top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」
进阶知识点
在基础知识点上进行的优化措施,提升列表性能,优化用户体验
缓冲机制
当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染
- 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
- 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲
动态高度缓存与更新
在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:
- 先进行预估高度 的渲染,渲染后通过nextTick获取真实高度
- 将真实高度写入缓存,并重新计算前缀和
- 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存
滚动事件节流
在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame
- 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑
二分查找优化索引定位
在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。
整体代码 ------ 组件封装(Vue 3 + TypeScript)
以下是一个支持 动态高度 、缓冲区 、高度缓存 、二分查找 的完整虚拟列表组件。
vue
<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>
常见问题 & 最佳实践
Q1:为什么我的虚拟列表在快速滚动时还是会白屏?
- 缓冲区太小 :适当增加
bufferCount(比如从 2 提升到 5)。 - 动态高度更新不及时 :确保在
nextTick后获取真实高度,并重新计算前缀和。 - 未使用
requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。
Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?
推荐使用 长度 = n+1 的前缀和数组 ,其中 prefixSum[0] = 0,prefixSum[i] 表示前 i 项的总高度。这样:
- 第 i 项的偏移量 =
prefixSum[i] - 总高度 =
prefixSum[n] - 查找
scrollTop对应索引时,二分查找第一个大于scrollTop的prefixSum[i],然后i-1即为起始索引。
Q3:如何支持列表项内容动态变化(比如展开/收起)?
- 监听内容变化,调用
updateRealHeights重新测量受影响的项。 - 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。
Q4:除了 transform 偏移,还有别的方案吗?
也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY。
总结
虚拟列表是前端性能优化中 性价比极高 的一类技术 ------ 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。
优化永无止境,如果你还想更进一步,可以探索:
- 使用
ResizeObserver监听每一项的尺寸变化,自动更新高度缓存。 - 结合
IntersectionObserver实现可视区外图片懒加载。 - 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的"无限滚动"。
希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~