Vue 3 虚拟列表与无限滚动实现指南
一、基础概念
虚拟列表原理
虚拟列表(Virtual List)是一种用于展示大量数据的优化技术,其核心思想是:
- 只渲染可视区域的数据
- 监听滚动位置动态更新显示内容
- 维护一个固定大小的渲染池
无限滚动原理
无限滚动(Infinite Scroll)是一种数据加载模式:
- 监听滚动到底部事件
- 触发新数据加载
- 合并现有数据和新数据
二、虚拟列表组件实现
1. 类型定义
// types.ts
export interface VirtualListProps {
data: any[]
itemHeight: number
containerHeight: number
buffer?: number
}
export interface ScrollData {
startIndex: number
endIndex: number
visibleCount: number
scrollTop: number
}
2. 核心组件实现
<!-- VirtualList.vue -->
<template>
<div
class="virtual-list-container"
:style="{ height: `${containerHeight}px` }"
@scroll="handleScroll"
ref="containerRef"
>
<div
class="virtual-list-phantom"
:style="{ height: `${phantomHeight}px` }"
/>
<div
class="virtual-list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
class="virtual-list-item"
:style="{ height: `${itemHeight}px` }"
>
<slot name="item" :item="item">
{{ item }}
</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import type { VirtualListProps, ScrollData } from './types'
const props = withDefaults(defineProps<VirtualListProps>(), {
buffer: 5
})
// 容器引用
const containerRef = ref<HTMLElement>()
// 滚动状态
const scrollData = ref<ScrollData>({
startIndex: 0,
endIndex: 0,
visibleCount: 0,
scrollTop: 0
})
// 计算总高度
const phantomHeight = computed(() =>
props.data.length * props.itemHeight
)
// 计算偏移量
const offsetY = computed(() =>
scrollData.value.startIndex * props.itemHeight
)
// 计算可见数据
const visibleData = computed(() => {
const { startIndex, endIndex } = scrollData.value
return props.data.slice(startIndex, endIndex + 1)
})
// 初始化计算可见区域
const initVisibleCount = () => {
if (!containerRef.value) return
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight)
const endIndex = Math.min(
visibleCount + props.buffer * 2,
props.data.length
)
scrollData.value = {
startIndex: 0,
endIndex,
visibleCount,
scrollTop: 0
}
}
// 处理滚动事件
const handleScroll = (e: Event) => {
const { scrollTop } = e.target as HTMLElement
const { visibleCount } = scrollData.value
// 计算起始索引
const startIndex = Math.floor(scrollTop / props.itemHeight)
// 计算结束索引
const endIndex = Math.min(
startIndex + visibleCount + props.buffer * 2,
props.data.length
)
scrollData.value = {
startIndex: Math.max(0, startIndex - props.buffer),
endIndex,
visibleCount,
scrollTop
}
}
// 监听数据变化
watch(
() => props.data.length,
() => initVisibleCount()
)
onMounted(() => {
initVisibleCount()
})
</script>
<style scoped>
.virtual-list-container {
position: relative;
overflow-y: auto;
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
will-change: transform;
}
</style>
三、无限滚动实现
1. 无限滚动 Hook
// useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue'
interface Options {
target?: HTMLElement | null
threshold?: number
immediate?: boolean
}
export function useInfiniteScroll(
loadMore: () => Promise<void>,
options: Options = {}
) {
const loading = ref(false)
const finished = ref(false)
const observer = ref<IntersectionObserver | null>(null)
const observerTarget = ref<HTMLElement | null>(null)
// 创建观察器
const createObserver = () => {
if (!options.target && !observerTarget.value) return
const targetElement = options.target || observerTarget.value
observer.value = new IntersectionObserver(
async (entries) => {
const entry = entries[0]
if (entry.isIntersecting && !loading.value && !finished.value) {
try {
loading.value = true
await loadMore()
} finally {
loading.value = false
}
}
},
{
threshold: options.threshold || 0.1
}
)
observer.value.observe(targetElement!)
}
// 销毁观察器
const destroyObserver = () => {
if (observer.value) {
observer.value.disconnect()
observer.value = null
}
}
onMounted(() => {
if (options.immediate) {
createObserver()
}
})
onUnmounted(() => {
destroyObserver()
})
return {
loading,
finished,
observerTarget
}
}
2. 组合使用示例
<template>
<div class="list-container">
<VirtualList
:data="items"
:item-height="60"
:container-height="400"
>
<template #item="{ item }">
<div class="list-item">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</template>
</VirtualList>
<!-- 加载触发器 -->
<div ref="loadingTrigger" v-show="!finished">
{{ loading ? '加载中...' : '继续加载' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VirtualList from './VirtualList.vue'
import { useInfiniteScroll } from './useInfiniteScroll'
const items = ref<any[]>([])
const pageSize = 20
const currentPage = ref(1)
// 加载更多数据
const loadMore = async () => {
// 模拟接口请求
const newItems = await fetchItems(currentPage.value, pageSize)
items.value.push(...newItems)
if (newItems.length < pageSize) {
finished.value = true
} else {
currentPage.value++
}
}
const { loading, finished, observerTarget } = useInfiniteScroll(loadMore, {
threshold: 0.5,
immediate: true
})
</script>
四、性能优化
1. 滚动优化
// 使用 rAF 优化滚动处理
const handleScroll = (e: Event) => {
if (scrollTimer) return
scrollTimer = requestAnimationFrame(() => {
// 滚动处理逻辑
updateVisibleData(e)
scrollTimer = null
})
}
// 使用 ResizeObserver 监听容器大小变化
const observeSize = () => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect
if (height !== containerHeight) {
updateContainerHeight(height)
}
}
})
if (containerRef.value) {
resizeObserver.observe(containerRef.value)
}
return () => resizeObserver.disconnect()
}
2. 渲染优化
// 使用 v-memo 优化列表项渲染
const shouldItemUpdate = (
prevItem: any,
nextItem: any
) => {
return prevItem.id === nextItem.id &&
prevItem.updated === nextItem.updated
}
// 组件中使用
<div
v-for="item in visibleData"
:key="item.id"
v-memo="[item.id, item.updated]"
>
<slot name="item" :item="item" />
</div>
3. 数据处理优化
// 使用分段处理大量数据
const processLargeData = (data: any[], chunkSize = 1000) => {
const chunks: any[][] = []
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize))
}
let processed: any[] = []
const processNextChunk = () => {
if (chunks.length === 0) return
const chunk = chunks.shift()!
processed = processed.concat(processChunk(chunk))
if (chunks.length > 0) {
requestIdleCallback(processNextChunk)
}
}
requestIdleCallback(processNextChunk)
}
五、扩展功能
1. 动态高度支持
// 使用预估高度和实际高度缓存
interface ItemSize {
estimated: number
actual: number | null
}
const itemSizes = new Map<string, ItemSize>()
const updateItemSize = (id: string, height: number) => {
const size = itemSizes.get(id)
if (size) {
size.actual = height
}
}
// 使用 ResizeObserver 监听项目高度
const observeItemSize = (el: HTMLElement, id: string) => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect
updateItemSize(id, height)
}
})
resizeObserver.observe(el)
return resizeObserver
}
2. 分组支持
// 分组虚拟列表实现
interface Group {
id: string
title: string
items: any[]
}
const calculateGroupPositions = (groups: Group[]) => {
let offset = 0
const positions = new Map<string, number>()
groups.forEach(group => {
positions.set(group.id, offset)
offset += group.items.length * itemHeight + groupHeaderHeight
})
return positions
}
// 获取可见分组
const getVisibleGroups = (scrollTop: number, viewportHeight: number) => {
const visibleGroups: Group[] = []
let currentOffset = 0
for (const group of groups.value) {
const groupHeight = calculateGroupHeight(group)
if (
currentOffset + groupHeight >= scrollTop &&
currentOffset <= scrollTop + viewportHeight
) {
visibleGroups.push(group)
}
currentOffset += groupHeight
}
return visibleGroups
}
六、最佳实践建议
-
性能考虑
- 使用
requestAnimationFrame
优化滚动处理 - 实现数据分片加载
- 合理设置缓冲区大小
- 使用
-
用户体验
- 添加加载状态指示
- 实现平滑滚动
- 保持滚动位置
-
代码质量
- 做好类型定义
- 组件解耦
- 错误处理
-
可维护性
- 清晰的代码结构
- 完整的文档
- 单元测试
总结
本指南涵盖了:
- 虚拟列表的基本实现
- 无限滚动的整合
- 性能优化策略
- 高级功能扩展
关键要点:
- 只渲染必要的数据
- 优化滚动性能
- 处理边界情况
- 提供良好的用户体验