Vue 3 虚拟列表与无限滚动实现指南

400 阅读4分钟

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
}

六、最佳实践建议

  1. 性能考虑

    • 使用 requestAnimationFrame 优化滚动处理
    • 实现数据分片加载
    • 合理设置缓冲区大小
  2. 用户体验

    • 添加加载状态指示
    • 实现平滑滚动
    • 保持滚动位置
  3. 代码质量

    • 做好类型定义
    • 组件解耦
    • 错误处理
  4. 可维护性

    • 清晰的代码结构
    • 完整的文档
    • 单元测试

总结

本指南涵盖了:

  1. 虚拟列表的基本实现
  2. 无限滚动的整合
  3. 性能优化策略
  4. 高级功能扩展

关键要点:

  • 只渲染必要的数据
  • 优化滚动性能
  • 处理边界情况
  • 提供良好的用户体验

参考资源