Vue3 中虚拟滚动与分页加载的实现原理与实践

前言

在现代前端应用中,处理大量数据展示是一个常见的挑战。当数据量达到数千甚至数万条时,传统的渲染方式会导致页面卡顿、内存占用过高等问题。今天我们来深入探讨 Vue3 中虚拟滚动和分页加载的实现方案。

一、为什么需要虚拟滚动和分页加载?

性能问题分析

javascript 复制代码
// 传统渲染方式的问题
const renderAllItems = (items) => {
  // 假设有 10000 条数据
  // 1. 创建 10000 个 DOM 节点,消耗大量内存
  // 2. 每个节点都需要样式计算和布局,耗时严重
  // 3. 滚动时频繁重绘,性能低下
}

解决方案对比

  • 分页加载:减少一次性加载的数据量

  • 虚拟滚动:只渲染可见区域的内容

  • 结合使用:两者优势互补,适用于超大数据集

二、虚拟滚动的实现原理

核心思想

虚拟滚动通过只渲染用户可见区域的内容来大幅提升性能。实现的关键在于:

  1. 容器固定高度:创建一个固定高度的视窗

  2. 计算可见区域:根据滚动位置确定显示哪些数据

  3. 动态渲染:只渲染可见区域的数据项

  4. 位置偏移:使用 transform 模拟滚动效果

基础实现代码

javascript 复制代码
<template>
  <div class="virtual-scroll-container" ref="container" @scroll="handleScroll">
    <!-- 撑开高度的占位元素 -->
    <div class="scroll-placeholder" :style="{ height: totalHeight + 'px' }"></div>
    
    <!-- 实际渲染的内容区域 -->
    <div class="visible-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div 
        v-for="item in visibleItems" 
        :key="getItemKey(item)"
        class="virtual-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  items: { type: Array, required: true },      // 所有数据
  itemHeight: { type: Number, default: 50 },   // 每项高度
  overscan: { type: Number, default: 5 },      // 额外渲染的项数(缓冲)
})

const container = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)

// 总高度(用于撑开滚动条)
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 可见区域的起始和结束索引
const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
})

const endIndex = computed(() => {
  const visibleCount = Math.ceil(containerHeight.value / props.itemHeight)
  return Math.min(
    props.items.length,
    startIndex.value + visibleCount + props.overscan * 2
  )
})

// 当前可见的数据项
const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value)
})

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

// 处理滚动事件
const handleScroll = () => {
  if (container.value) {
    scrollTop.value = container.value.scrollTop
  }
}

// 获取项的唯一键
const getItemKey = (item) => {
  return item.id || JSON.stringify(item)
}

// 监听容器大小变化
let resizeObserver = null
onMounted(() => {
  if (container.value) {
    containerHeight.value = container.value.clientHeight
    
    // 使用 ResizeObserver 监听容器大小变化
    resizeObserver = new ResizeObserver((entries) => {
      for (let entry of entries) {
        containerHeight.value = entry.contentRect.height
      }
    })
    resizeObserver.observe(container.value)
  }
})

onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect()
  }
})
</script>

<style scoped>
.virtual-scroll-container {
  position: relative;
  height: 500px;
  overflow-y: auto;
  border: 1px solid #e4e7ed;
}

.scroll-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.visible-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  will-change: transform; /* 优化动画性能 */
}

.virtual-item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
  box-sizing: border-box;
  overflow: hidden;
}
</style>

三、分页加载的实现方案

基本分页加载

javascript 复制代码
<template>
  <div class="pagination-container">
    <!-- 数据列表 -->
    <div class="data-list">
      <div v-for="item in currentData" :key="item.id" class="data-item">
        {{ item.name }}
      </div>
    </div>
    
    <!-- 加载更多 -->
    <div v-if="hasMore" class="load-more">
      <button 
        @click="loadNextPage" 
        :disabled="loading"
        class="load-btn"
      >
        {{ loading ? '加载中...' : '加载更多' }}
      </button>
    </div>
    
    <!-- 分页器 -->
    <div class="pagination" v-if="showPagination">
      <button 
        @click="prevPage" 
        :disabled="currentPage === 1"
        class="page-btn"
      >
        上一页
      </button>
      
      <span class="page-info">
        第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
      </span>
      
      <button 
        @click="nextPage" 
        :disabled="currentPage === totalPages"
        class="page-btn"
      >
        下一页
      </button>
    </div>
  </div>
</template>

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

// 模拟 API 请求
const mockFetchData = (page, pageSize) => {
  return new Promise(resolve => {
    setTimeout(() => {
      const data = Array.from({ length: pageSize }, (_, i) => ({
        id: (page - 1) * pageSize + i + 1,
        name: `项目 ${(page - 1) * pageSize + i + 1}`
      }))
      resolve(data)
    }, 500)
  })
}

// 响应式数据
const allData = ref([])           // 所有已加载的数据
const currentPage = ref(1)        // 当前页码
const pageSize = ref(20)          // 每页大小
const totalItems = ref(0)         // 总数据量
const loading = ref(false)        // 加载状态

// 计算属性
const totalPages = computed(() => {
  return Math.ceil(totalItems.value / pageSize.value)
})

const currentData = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return allData.value.slice(start, end)
})

const hasMore = computed(() => {
  return allData.value.length < totalItems.value
})

const showPagination = computed(() => {
  return totalPages.value > 1
})

// 加载数据
const loadData = async (page = 1) => {
  if (loading.value) return
  
  loading.value = true
  try {
    // 模拟 API 调用
    const response = await mockFetchData(page, pageSize.value)
    
    // 如果是第一页,重置数据
    if (page === 1) {
      allData.value = response
    } else {
      // 追加数据(用于加载更多模式)
      allData.value = [...allData.value, ...response]
    }
    
    // 模拟总数据量
    totalItems.value = 1000
  } catch (error) {
    console.error('加载数据失败:', error)
  } finally {
    loading.value = false
  }
}

// 分页操作
const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--
  }
}

const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    currentPage.value++
  }
}

const loadNextPage = async () => {
  const nextPage = Math.ceil(allData.value.length / pageSize.value) + 1
  await loadData(nextPage)
}

// 初始化加载
onMounted(() => {
  loadData(1)
})
</script>

四、虚拟滚动与分页加载的结合

高级实现:支持动态高度的虚拟滚动分页

javascript 复制代码
<template>
  <div class="virtual-pagination-container">
    <!-- 虚拟滚动容器 -->
    <div 
      class="virtual-scroll-wrapper"
      ref="scrollContainer"
      @scroll="handleVirtualScroll"
    >
      <!-- 撑开容器 -->
      <div 
        class="scroll-content"
        :style="{ height: estimatedTotalHeight + 'px' }"
      >
        <!-- 可见项 -->
        <div 
          v-for="item in visibleItems"
          :key="item.id"
          class="virtual-item"
          :style="getItemStyle(item)"
          ref="itemRefs"
        >
          <slot name="item" :item="item">
            <div>{{ item.content }}</div>
          </slot>
        </div>
      </div>
    </div>
    
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-indicator">
      正在加载更多数据...
    </div>
    
    <!-- 数据统计 -->
    <div class="stats">
      已加载 {{ loadedItems.length }} 条,总共约 {{ estimatedTotalCount }} 条
    </div>
  </div>
</template>

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

const props = defineProps({
  fetchData: { type: Function, required: true },      // 数据获取函数
  pageSize: { type: Number, default: 50 },            // 每页大小
  estimateItemHeight: { type: Number, default: 60 },  // 预估项目高度
  bufferSize: { type: Number, default: 100 },         // 缓冲大小
})

const emit = defineEmits(['visible-items-change'])

// 响应式数据
const scrollContainer = ref(null)
const itemRefs = ref([])
const scrollTop = ref(0)
const containerHeight = ref(0)
const loadedItems = ref([])           // 已加载的数据
const itemHeights = ref(new Map())    // 记录实际高度
const loading = ref(false)
const hasMore = ref(true)
const currentPage = ref(0)

// 估计的总项目数(根据已加载数据估算)
const estimatedTotalCount = computed(() => {
  if (!hasMore.value) return loadedItems.value.length
  return Math.max(
    loadedItems.value.length,
    Math.ceil(loadedItems.value.length / (currentPage.value || 1)) * 100
  )
})

// 估算总高度
const estimatedTotalHeight = computed(() => {
  let total = 0
  for (let i = 0; i < estimatedTotalCount.value; i++) {
    total += itemHeights.value.get(i) || props.estimateItemHeight
  }
  return total
})

// 计算可见区域
const visibleRange = computed(() => {
  let start = 0
  let end = 0
  let accumulatedHeight = 0
  
  // 计算起始索引
  for (let i = 0; i < estimatedTotalCount.value; i++) {
    const height = itemHeights.value.get(i) || props.estimateItemHeight
    if (accumulatedHeight + height > scrollTop.value) {
      start = Math.max(0, i - props.bufferSize)
      break
    }
    accumulatedHeight += height
  }
  
  // 计算结束索引
  let visibleHeight = 0
  for (let i = start; i < estimatedTotalCount.value; i++) {
    const height = itemHeights.value.get(i) || props.estimateItemHeight
    visibleHeight += height
    if (visibleHeight > containerHeight.value * 2) { // 多渲染一些
      end = Math.min(estimatedTotalCount.value, i + props.bufferSize)
      break
    }
  }
  
  if (end === 0) end = estimatedTotalCount.value
  
  return { start, end }
})

// 可见项目
const visibleItems = computed(() => {
  const { start, end } = visibleRange.value
  return loadedItems.value.slice(start, end)
})

// 获取项目样式
const getItemStyle = (item, index) => {
  const itemIndex = loadedItems.value.findIndex(i => i.id === item.id)
  let top = 0
  for (let i = 0; i < itemIndex; i++) {
    top += itemHeights.value.get(i) || props.estimateItemHeight
  }
  
  return {
    position: 'absolute',
    top: `${top}px`,
    left: '0',
    right: '0',
    height: `${itemHeights.value.get(itemIndex) || props.estimateItemHeight}px`
  }
}

// 处理滚动
const handleVirtualScroll = () => {
  if (!scrollContainer.value) return
  
  scrollTop.value = scrollContainer.value.scrollTop
  
  // 检查是否需要加载更多
  const scrollBottom = scrollTop.value + containerHeight.value
  const totalHeight = estimatedTotalHeight.value
  
  if (totalHeight - scrollBottom < 500 && hasMore.value && !loading.value) {
    loadMoreData()
  }
}

// 加载更多数据
const loadMoreData = async () => {
  if (loading.value || !hasMore.value) return
  
  loading.value = true
  try {
    const newData = await props.fetchData(currentPage.value + 1, props.pageSize)
    
    if (newData.length === 0) {
      hasMore.value = false
      return
    }
    
    // 添加新数据
    const startIndex = loadedItems.value.length
    loadedItems.value = [...loadedItems.value, ...newData]
    currentPage.value++
    
    // 等待 DOM 更新后测量实际高度
    await nextTick()
    updateItemHeights(startIndex)
    
  } catch (error) {
    console.error('加载数据失败:', error)
  } finally {
    loading.value = false
  }
}

// 更新项目高度
const updateItemHeights = (startIndex) => {
  itemRefs.value.forEach((el, index) => {
    if (el && index >= startIndex) {
      const itemIndex = visibleRange.value.start + index
      const height = el.getBoundingClientRect().height
      itemHeights.value.set(itemIndex, height)
    }
  })
}

// 监听可见项目变化
watch(visibleItems, (newItems) => {
  emit('visible-items-change', newItems)
})

// 初始化
onMounted(async () => {
  if (scrollContainer.value) {
    containerHeight.value = scrollContainer.value.clientHeight
  }
  
  // 初始加载
  await loadMoreData()
})

// 暴露方法
defineExpose({
  scrollToIndex: (index) => {
    if (scrollContainer.value) {
      let top = 0
      for (let i = 0; i < index; i++) {
        top += itemHeights.value.get(i) || props.estimateItemHeight
      }
      scrollContainer.value.scrollTop = top
    }
  }
})
</script>

五、性能优化建议

1. 使用防抖和节流

javascript 复制代码
import { throttle } from 'lodash-es'

// 节流滚动事件处理
const handleScrollThrottled = throttle(handleScroll, 16) // 60fps

2. 避免不必要的重新渲染

javascript 复制代码
<script setup>
import { computed, shallowRef } from 'vue'

// 使用 shallowRef 避免深层响应式
const largeData = shallowRef([])

// 使用 computed 缓存计算结果
const processedData = computed(() => {
  return largeData.value.map(item => transformItem(item))
})
</script>

3. 使用 Intersection Observer 实现懒加载

javascript 复制代码
// 图片懒加载示例
const setupLazyLoad = (container) => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target
        img.src = img.dataset.src
        observer.unobserve(img)
      }
    })
  }, {
    root: container,
    threshold: 0.1
  })
  
  return observer
}

4. 虚拟滚动优化技巧

javascript 复制代码
// 使用 CSS contain 属性优化
.virtual-item {
  contain: strict; /* 或 contain: content */
}

// 使用 will-change 提示浏览器
.visible-content {
  will-change: transform;
}

// 硬件加速优化
.virtual-item {
  transform: translateZ(0);
  backface-visibility: hidden;
}

六、实际应用场景

场景1:大数据表格

javascript 复制代码
<template>
  <VirtualScrollTable
    :columns="columns"
    :data="tableData"
    :row-height="60"
    :buffer-size="10"
    @load-more="loadTableData"
  />
</template>

场景2:聊天记录

javascript 复制代码
<template>
  <VirtualScrollChat
    :messages="messages"
    :estimate-height="80"
    :reverse="true"  // 反向滚动
    @scroll-to-top="loadHistory"
  />
</template>

场景3:无限滚动列表

javascript 复制代码
<template>
  <InfiniteScrollList
    :items="products"
    :page-size="20"
    :threshold="200"
    @load-more="loadMoreProducts"
  >
    <template #item="{ item }">
      <ProductCard :product="item" />
    </template>
  </InfiniteScrollList>
</template>

七、常见问题与解决方案

Q1:动态高度如何处理?

A:使用预估高度 + 实际测量 + 位置缓存

Q2:快速滚动时出现白屏?

A:增加缓冲区域大小,使用更好的节流策略

Q3:内存占用过高?

A:及时清理不可见项目的数据,使用虚拟化技术

Q4:如何保持滚动位置?

A:记录滚动位置和可见项目索引,恢复时重新计算

八、总结

虚拟滚动和分页加载是现代前端应用性能优化的关键技术。在 Vue3 中,我们可以利用 Composition API 的响应式特性和计算属性,结合现代浏览器 API,实现高性能的数据展示方案。

核心要点:

  1. 理解虚拟滚动的核心原理:只渲染可见区域

  2. 合理使用分页加载减少单次数据量

  3. 结合两者处理超大数据集

  4. 关注性能指标,持续优化

希望本文能帮助你在项目中更好地实现高性能的数据展示。如果有任何问题或建议,欢迎在评论区讨论!

相关推荐
GIS之路8 小时前
GDAL 实现矢量合并
前端
hxjhnct8 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子8 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗9 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
小宇的天下9 小时前
Calibre 3Dstack Flow Example(5-2)
性能优化
前端工作日常9 小时前
我学习到的AG-UI的概念
前端
韩师傅9 小时前
前端开发消亡史:AI也无法掩盖没有设计创造力的真相
前端·人工智能·后端
Tisfy9 小时前
网站访问耗时优化 - 从数十秒到几百毫秒的“零成本”优化过程
服务器·开发语言·性能优化·php·网站·建站
XiaoYu20029 小时前
第12章 支付宝SDK
前端