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. 关注性能指标,持续优化

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

相关推荐
️学习的小王18 分钟前
Windows Claude Code 接入 Playwright‑MCP,调用本机Edge浏览器(避坑完整教程)
前端·windows·edge
律宏阔25 分钟前
CloakBrowser 开发踩坑笔记
前端·浏览器
Made in Haven71231 分钟前
HTML课程笔记补2
前端·笔记·html
Cry丶40 分钟前
Vue 3 业务管理页面实战:组件拆分、父子通信与弹窗复用
前端·javascript·vue.js·父子通信·组件拆分·弹窗复用
雪芽蓝域zzs1 小时前
Vue前端配置路由通配捕获 404
前端·javascript·vue.js
计算机魔术师1 小时前
Anthropic 发布电商 Agent 架构与生产实践指南,并开源 commerce-agents 参考实现
前端
IMPYLH1 小时前
HTML 的 <ol> 元素
前端·html
IMPYLH1 小时前
HTML 的 <object> 元素
前端·html
DevOpenClub1 小时前
PDF 多格式解析如何避免混用输出:TEXT、HTML、XML 与 TAG 数据契约
xml·前端·数据库·pdf·html
用户252632753471 小时前
请问同一台服务器能同时部署前后端项目并支持https吗?
前端