Vue 3 无限列表虚拟滚动完整实现

Vue 3 无限列表虚拟滚动完整实现

以下是基于 Vue 3 的无限列表虚拟滚动完整实现方案:

1. 基础虚拟滚动组件

1.1 固定高度虚拟列表

<template>
  <div
    ref="containerRef"
    class="virtual-scroll-container"
    @scroll="handleScroll"
  >
    <!-- 撑开容器高度的占位元素 -->
    <div
      class="virtual-scroll-placeholder"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 可见项目容器 -->
    <div
      class="virtual-scroll-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="item in visibleItems"
        :key="getItemKey(item)"
        class="virtual-scroll-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <slot :item="item" :index="item.__index" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'

const props = defineProps({
  // 数据源
  items: {
    type: Array,
    required: true
  },
  // 每项高度
  itemHeight: {
    type: Number,
    default: 50
  },
  // 容器高度
  height: {
    type: Number,
    default: 400
  },
  // 预渲染数量
  overscan: {
    type: Number,
    default: 5
  },
  // 项的唯一键
  itemKey: {
    type: [String, Function],
    default: 'id'
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)

// 计算可见区域
const visibleRange = computed(() => {
  const startIndex = Math.max(
    0,
    Math.floor(scrollTop.value / props.itemHeight) - props.overscan
  )
  const endIndex = Math.min(
    props.items.length - 1,
    startIndex + Math.ceil(props.height / props.itemHeight) + props.overscan * 2
  )
  return { startIndex, endIndex }
})

// 可见项数据
const visibleItems = computed(() => {
  const { startIndex, endIndex } = visibleRange.value
  return props.items.slice(startIndex, endIndex + 1).map((item, index) => ({
    ...item,
    __index: startIndex + index
  }))
})

// 总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// Y轴偏移量
const offsetY = computed(() => visibleRange.value.startIndex * props.itemHeight)

// 获取项的唯一键
const getItemKey = (item) => {
  if (typeof props.itemKey === 'function') {
    return props.itemKey(item)
  }
  return item[props.itemKey]
}

// 滚动处理
const handleScroll = (event) => {
  scrollTop.value = event.target.scrollTop
}

// 滚动到指定位置
const scrollTo = (index) => {
  if (containerRef.value) {
    containerRef.value.scrollTop = index * props.itemHeight
  }
}

// 滚动到项
const scrollToItem = (item) => {
  const index = props.items.findIndex(i => getItemKey(i) === getItemKey(item))
  if (index !== -1) {
    scrollTo(index)
  }
}

defineExpose({
  scrollTo,
  scrollToItem
})
</script>

<style scoped>
.virtual-scroll-container {
  height: v-bind('props.height + "px"');
  overflow: auto;
  position: relative;
}

.virtual-scroll-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtual-scroll-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtual-scroll-item {
  position: absolute;
  width: 100%;
  box-sizing: border-box;
}
</style>

1.2 使用示例

<template>
  <div class="demo-container">
    <h2>虚拟滚动列表 ({{ totalItems }} 项)</h2>
    
    <VirtualList
      :items="items"
      :item-height="60"
      :height="500"
      :overscan="5"
    >
      <template #default="{ item, index }">
        <div class="list-item" :class="{ even: index % 2 === 0 }">
          <div class="item-header">
            <span class="item-index">#{{ index + 1 }}</span>
            <span class="item-name">{{ item.name }}</span>
          </div>
          <div class="item-content">{{ item.content }}</div>
          <div class="item-footer">
            <span class="item-date">{{ item.date }}</span>
            <span class="item-tag" :style="{ backgroundColor: item.color }">
              {{ item.tag }}
            </span>
          </div>
        </div>
      </template>
    </VirtualList>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import VirtualList from './components/VirtualList.vue'

const totalItems = 10000
const items = ref([])

// 生成模拟数据
const generateItems = () => {
  const tags = ['前端', '后端', '移动端', 'AI', '数据库', '运维']
  const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3']
  
  return Array.from({ length: totalItems }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`,
    content: `这是第 ${index + 1} 个项目的内容描述,用于演示虚拟滚动效果。`,
    date: new Date(Date.now() - Math.random() * 1e10).toLocaleDateString(),
    tag: tags[index % tags.length],
    color: colors[index % colors.length]
  }))
}

onMounted(() => {
  items.value = generateItems()
})
</script>

<style scoped>
.demo-container {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #e1e1e1;
  background: white;
  transition: background-color 0.2s;
}

.list-item:hover {
  background-color: #f5f5f5;
}

.list-item.even {
  background-color: #fafafa;
}

.item-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.item-index {
  font-size: 12px;
  color: #666;
  font-weight: bold;
}

.item-name {
  font-weight: bold;
  color: #333;
}

.item-content {
  font-size: 14px;
  color: #666;
  margin-bottom: 8px;
  line-height: 1.4;
}

.item-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 12px;
}

.item-date {
  color: #999;
}

.item-tag {
  padding: 2px 8px;
  border-radius: 12px;
  color: white;
  font-size: 11px;
}
</style>

2. 可变高度虚拟列表

2.1 动态高度虚拟列表组件

<template>
  <div
    ref="containerRef"
    class="dynamic-virtual-list"
    @scroll="handleScroll"
  >
    <div
      class="list-placeholder"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <div class="list-content">
      <div
        v-for="item in visibleItems"
        :key="getItemKey(item)"
        ref="itemRefs"
        class="list-item"
        :data-index="item.__index"
      >
        <slot :item="item" :index="item.__index" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  height: {
    type: Number,
    default: 400
  },
  estimatedItemHeight: {
    type: Number,
    default: 50
  },
  overscan: {
    type: Number,
    default: 5
  },
  itemKey: {
    type: [String, Function],
    default: 'id'
  }
})

const containerRef = ref(null)
const itemRefs = ref([])
const scrollTop = ref(0)

// 位置缓存
const positions = ref([])
const itemHeights = ref(new Map())

// 初始化位置信息
const initPositions = () => {
  positions.value = props.items.map((item, index) => {
    const key = getItemKey(item)
    const cachedHeight = itemHeights.value.get(key)
    return {
      index,
      key,
      top: index * props.estimatedItemHeight,
      height: cachedHeight || props.estimatedItemHeight,
      bottom: (index + 1) * props.estimatedItemHeight
    }
  })
}

// 更新位置信息
const updatePositions = () => {
  let top = 0
  positions.value = props.items.map((item, index) => {
    const key = getItemKey(item)
    const height = itemHeights.value.get(key) || props.estimatedItemHeight
    const position = {
      index,
      key,
      top,
      height,
      bottom: top + height
    }
    top += height
    return position
  })
}

// 总高度
const totalHeight = computed(() => {
  if (positions.value.length === 0) return 0
  return positions.value[positions.value.length - 1].bottom
})

// 获取可见范围
const getVisibleRange = () => {
  const startIndex = findNearestIndex(scrollTop.value)
  const endIndex = findNearestIndex(scrollTop.value + props.height)
  
  return {
    startIndex: Math.max(0, startIndex - props.overscan),
    endIndex: Math.min(props.items.length - 1, endIndex + props.overscan)
  }
}

// 二分查找最近索引
const findNearestIndex = (scrollTop) => {
  let left = 0
  let right = positions.value.length - 1
  let index = -1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const midValue = positions.value[mid]
    
    if (midValue.bottom >= scrollTop) {
      index = mid
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  
  return index === -1 ? positions.value.length - 1 : index
}

// 可见项数据
const visibleItems = computed(() => {
  const { startIndex, endIndex } = getVisibleRange()
  return props.items.slice(startIndex, endIndex + 1).map((item, index) => ({
    ...item,
    __index: startIndex + index
  }))
})

// 获取项的唯一键
const getItemKey = (item) => {
  if (typeof props.itemKey === 'function') {
    return props.itemKey(item)
  }
  return item[props.itemKey]
}

// 滚动处理
const handleScroll = (event) => {
  scrollTop.value = event.target.scrollTop
}

// 测量项高度并更新位置
const measureItems = () => {
  nextTick(() => {
    let updated = false
    
    itemRefs.value.forEach((itemEl) => {
      if (!itemEl) return
      
      const index = parseInt(itemEl.dataset.index)
      const item = props.items[index]
      const key = getItemKey(item)
      const height = itemEl.offsetHeight
      
      const oldHeight = itemHeights.value.get(key)
      if (oldHeight !== height) {
        itemHeights.value.set(key, height)
        updated = true
      }
    })
    
    if (updated) {
      updatePositions()
    }
  })
}

// 监听数据变化
watch(() => props.items, () => {
  initPositions()
}, { deep: true })

// 监听可见项变化,重新测量高度
watch(visibleItems, () => {
  measureItems()
})

onMounted(() => {
  initPositions()
})

defineExpose({
  scrollTo: (index) => {
    if (containerRef.value && positions.value[index]) {
      containerRef.value.scrollTop = positions.value[index].top
    }
  }
})
</script>

<style scoped>
.dynamic-virtual-list {
  height: v-bind('props.height + "px"');
  overflow: auto;
  position: relative;
}

.list-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.list-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.list-item {
  position: absolute;
  width: 100%;
  box-sizing: border-box;
}
</style>

3. 无限滚动虚拟列表

3.1 无限滚动虚拟列表

<template>
  <div class="infinite-virtual-list">
    <VirtualList
      ref="virtualListRef"
      :items="visibleData"
      :item-height="itemHeight"
      :height="containerHeight"
      :overscan="overscan"
      @scroll="handleVirtualScroll"
    >
      <template #default="{ item, index }">
        <slot :item="item" :index="index" :loading="item.__loading" />
      </template>
    </VirtualList>
    
    <div v-if="loading" class="loading-indicator">
      加载中...
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import VirtualList from './VirtualList.vue'

const props = defineProps({
  // 数据获取函数
  fetchData: {
    type: Function,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  },
  overscan: {
    type: Number,
    default: 10
  },
  pageSize: {
    type: Number,
    default: 50
  },
  threshold: {
    type: Number,
    default: 100
  }
})

const emit = defineEmits(['load-more', 'data-loaded'])

const virtualListRef = ref(null)
const allData = ref([])
const currentPage = ref(0)
const loading = ref(false)
const hasMore = ref(true)
const scrollTop = ref(0)

// 可见数据(当前加载的数据)
const visibleData = computed(() => allData.value)

// 加载更多数据
const loadMore = async () => {
  if (loading.value || !hasMore.value) return
  
  loading.value = true
  try {
    currentPage.value++
    
    // 添加 loading 项
    allData.value.push({
      id: `loading-${Date.now()}`,
      __loading: true
    })
    
    const newData = await props.fetchData(currentPage.value, props.pageSize)
    
    // 移除 loading 项
    allData.value = allData.value.filter(item => !item.__loading)
    
    if (newData.length === 0) {
      hasMore.value = false
      return
    }
    
    allData.value.push(...newData)
    emit('data-loaded', newData, currentPage.value)
    
    // 如果数据不足一屏,继续加载
    if (allData.value.length < props.pageSize * 2) {
      await loadMore()
    }
  } catch (error) {
    console.error('加载数据失败:', error)
    // 移除 loading 项
    allData.value = allData.value.filter(item => !item.__loading)
    currentPage.value--
  } finally {
    loading.value = false
  }
}

// 虚拟滚动处理
const handleVirtualScroll = (event) => {
  scrollTop.value = event.target.scrollTop
  
  const scrollHeight = event.target.scrollHeight
  const clientHeight = event.target.clientHeight
  const scrollBottom = scrollHeight - scrollTop.value - clientHeight
  
  // 接近底部时加载更多
  if (scrollBottom < props.threshold && hasMore.value && !loading.value) {
    emit('load-more', currentPage.value + 1)
    loadMore()
  }
}

// 重新加载
const reload = async () => {
  allData.value = []
  currentPage.value = 0
  hasMore.value = true
  await loadMore()
}

// 滚动到顶部
const scrollToTop = () => {
  if (virtualListRef.value) {
    virtualListRef.value.scrollTo(0)
  }
}

defineExpose({
  reload,
  scrollToTop
})

onMounted(() => {
  loadMore()
})
</script>

<style scoped>
.infinite-virtual-list {
  position: relative;
}

.loading-indicator {
  padding: 16px;
  text-align: center;
  color: #666;
  background: #f5f5f5;
}
</style>

4. 性能优化技巧

4.1 使用 shallowRef 优化大数据量

import { shallowRef } from 'vue'

// 对于大数据量,使用 shallowRef 避免深度响应式
const largeData = shallowRef([])

// 批量更新数据
const updateData = (newData) => {
  largeData.value = newData
}

4.2 防抖滚动处理

import { debounce } from 'lodash-es'

const handleScroll = debounce((event) => {
  scrollTop.value = event.target.scrollTop
}, 16) // 约 60fps

4.3 使用 CSS will-change 优化

.virtual-scroll-content {
  will-change: transform;
}

.virtual-scroll-item {
  will-change: transform;
  contain: layout style paint;
}

5. 完整使用示例

<template>
  <div class="infinite-list-demo">
    <div class="controls">
      <button @click="reload">重新加载</button>
      <button @click="scrollToTop">滚动到顶部</button>
      <span>已加载: {{ data.length }} 项</span>
    </div>
    
    <InfiniteVirtualList
      ref="infiniteListRef"
      :fetch-data="fetchData"
      :item-height="80"
      :container-height="600"
      :page-size="30"
      @load-more="onLoadMore"
      @data-loaded="onDataLoaded"
    >
      <template #default="{ item, index, loading }">
        <div v-if="loading" class="loading-item">
          加载中...
        </div>
        <div v-else class="data-item">
          <div class="item-header">
            <h3>{{ item.title }}</h3>
            <span class="index">#{{ index + 1 }}</span>
          </div>
          <p class="item-content">{{ item.content }}</p>
          <div class="item-meta">
            <span class="author">{{ item.author }}</span>
            <span class="time">{{ item.time }}</span>
          </div>
        </div>
      </template>
    </InfiniteVirtualList>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import InfiniteVirtualList from './components/InfiniteVirtualList.vue'

const infiniteListRef = ref(null)
const data = ref([])

// 模拟数据获取
const fetchData = async (page, pageSize) => {
  // 模拟网络延迟
  await new Promise(resolve => setTimeout(resolve, 500))
  
  const startIndex = (page - 1) * pageSize
  return Array.from({ length: pageSize }, (_, i) => ({
    id: startIndex + i + 1,
    title: `项目 ${startIndex + i + 1}`,
    content: `这是第 ${startIndex + i + 1} 个项目的内容描述。虚拟滚动技术可以高效处理大量数据。`,
    author: `作者 ${(startIndex + i) % 10 + 1}`,
    time: new Date(Date.now() - Math.random() * 1e9).toLocaleString()
  }))
}

const onLoadMore = (page) => {
  console.log('开始加载第', page, '页')
}

const onDataLoaded = (newData, page) => {
  data.value = [...data.value, ...newData]
  console.log('第', page, '页加载完成,共', data.value.length, '项')
}

const reload = () => {
  data.value = []
  infiniteListRef.value?.reload()
}

const scrollToTop = () => {
  infiniteListRef.value?.scrollToTop()
}
</script>

<style scoped>
.infinite-list-demo {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.controls {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
  align-items: center;
}

.loading-item {
  padding: 20px;
  text-align: center;
  background: #f8f9fa;
  color: #6c757d;
  border-radius: 4px;
}

.data-item {
  padding: 16px;
  border: 1px solid #e9ecef;
  border-radius: 6px;
  margin-bottom: 8px;
  background: white;
  transition: box-shadow 0.2s;
}

.data-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.item-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.item-header h3 {
  margin: 0;
  color: #333;
}

.index {
  color: #6c757d;
  font-size: 12px;
}

.item-content {
  color: #666;
  line-height: 1.5;
  margin-bottom: 8px;
}

.item-meta {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #999;
}
</style>

这个实现方案提供了:

  • ✅ 固定高度虚拟滚动
  • ✅ 可变高度虚拟滚动
  • ✅ 无限滚动加载
  • ✅ 性能优化
  • ✅ 完整的 TypeScript 支持(可扩展)
  • ✅ 灵活的插槽系统

可以根据具体需求选择合适的实现方案。

posted @ 2025-10-13 20:59  阿木隆1237  阅读(388)  评论(0)    收藏  举报