Vue 虚拟列表实现方案详解:三种方法的完整对比与实践

前言

在现代Web开发中,当需要渲染大量数据列表时,传统的DOM渲染方式会导致严重的性能问题。虚拟列表(Virtual List)技术通过只渲染可视区域内的元素,大大提升了大数据量列表的渲染性能。

本文将详细介绍三种Vue虚拟列表的实现方案:

  1. 手写原理实现 - 深入理解虚拟列表核心原理

  2. VueUse库实现 - 利用组合式API的强大功能

  3. TanStack Virtual - 专业的虚拟化解决方案

技术背景

什么是虚拟列表?

虚拟列表是一种优化长列表渲染性能的技术,其核心思想是:

  • 只渲染用户当前可见的列表项

  • 通过滚动事件动态更新可见区域

  • 使用占位元素维持正确的滚动条高度

为什么需要虚拟列表?

当列表数据量达到数千甚至数万条时,传统渲染方式会遇到:

  • DOM节点过多:导致页面卡顿

  • 内存占用高:大量DOM元素占用内存

  • 初始渲染慢:首次加载时间过长

  • 滚动不流畅:滚动时出现明显延迟

项目环境配置

依赖安装

复制代码
# 创建Vue项目
npm create vue@latest virtual-list-demo
cd virtual-list-demo
​
# 安装核心依赖
npm install vue@^3.5.22
​
# 安装HTTP请求库
npm install axios@^1.12.2
​
# 安装VueUse(方案二需要)
npm install @vueuse/core@^13.9.0
​
# 安装TanStack Virtual(方案三需要)
npm install @tanstack/vue-virtual@^3.13.12
​
# 安装开发依赖
npm install -D @vitejs/plugin-vue@^6.0.1 vite@^7.1.7

package.json 配置

javascript 复制代码
{
  "name": "virtual-list",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tanstack/vue-virtual": "^3.13.12",
    "@vueuse/core": "^13.9.0",
    "axios": "^1.12.2",
    "vue": "^3.5.22"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "vite": "^7.1.7"
  }
}

方案一:手写原理实现

核心原理

手写实现虚拟列表需要理解以下核心概念:

  • 可视区域计算:根据容器高度和项目高度计算可显示的项目数量

  • 滚动监听:监听滚动事件,动态计算起始索引

  • 位置偏移 :使用transform: translateY()定位可视区域

  • 占位元素:维持正确的滚动条总高度

完整代码实现

javascript 复制代码
<script setup>
import { ref, computed, nextTick } from 'vue'
import axios from 'axios'
​
const LIST_DATA = ref([])
const getData = async () => {
  const {data} = await axios.get('/api/mock/68e0c49dbf906d0623008434/api/v1/test#!method=get')
  LIST_DATA.value = data.data
}
​
// 虚拟列表配置
const listHeight = ref(60) // 每个列表项的高度
const showListCount = ref(10) // 可视区域显示的项目数量
const containerHeight = computed(() => showListCount.value * listHeight.value) // 容器高度
​
// 索引和偏移
const startIndex = ref(0)
const endIndex = computed(() => Math.min(startIndex.value + showListCount.value, LIST_DATA.value.length))
const offsetY = ref(0)
​
// 显示的数据
const showData = computed(() => {
  return LIST_DATA.value.slice(startIndex.value, endIndex.value)
})
​
// 总高度(用于撑开滚动条)
const totalHeight = computed(() => LIST_DATA.value.length * listHeight.value)
​
// 滚动事件处理
const handleScroll = (event) => {
  const scrollTop = event.target.scrollTop
  // 计算当前应该显示的起始索引
  startIndex.value = Math.floor(scrollTop / listHeight.value)
  // 计算偏移量,用于定位可视区域
  offsetY.value = startIndex.value * listHeight.value
}
</script>
​
<template>
  <div class="virtual-list-container">
    <h1>手写虚拟列表</h1>
    <button @click="getData" class="load-btn">获取数据</button>
    
    <div class="list-info">
      <span>总数据量: {{ LIST_DATA.length }}</span>
      <span>当前显示: {{ startIndex + 1 }} - {{ endIndex }}</span>
    </div>
​
    <!-- 虚拟列表容器 -->
    <div 
      class="virtual-list-wrapper"
      :style="{ height: containerHeight + 'px' }"
      @scroll="handleScroll"
    >
      <!-- 占位元素,用于撑开滚动条 -->
      <div 
        class="virtual-list-phantom" 
        :style="{ height: totalHeight + 'px' }"
      ></div>
      
      <!-- 可视区域 -->
      <div 
        class="virtual-list-content"
        :style="{ transform: `translateY(${offsetY}px)` }"
      >
        <div 
          v-for="item in showData" 
          :key="item.id"
          class="list-item"
          :style="{ height: listHeight + 'px' }"
        >
          <div class="item-content">
            <span class="item-title">{{ item.title }}</span>
            <span class="item-id">ID: {{ item.id }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
​
<style scoped>
.virtual-list-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
​
.virtual-list-wrapper {
  position: relative;
  overflow: auto;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
​
.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;
}
​
.list-item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
  transition: background-color 0.2s;
}
​
.list-item:hover {
  background-color: #f5f5f5;
}
</style>

API 接口说明

属性 类型 默认值 说明
listHeight number 60 每个列表项的固定高度(px)
showListCount number 10 可视区域显示的项目数量
startIndex number 0 当前显示的起始索引
endIndex number - 当前显示的结束索引(计算属性)
offsetY number 0 可视区域的垂直偏移量

核心方法

  • handleScroll(event): 处理滚动事件,更新显示区域

  • getData(): 异步获取列表数据

方案二:VueUse 实现

技术优势

VueUse提供了强大的组合式API,让虚拟列表实现更加优雅:

  • useScroll: 响应式滚动监听,支持节流

  • useElementSize: 自动监听元素尺寸变化

  • useAsyncState: 优雅的异步状态管理

  • useThrottleFn: 内置节流函数

完整代码实现

javascript 复制代码
<script setup>
import { ref, computed } from 'vue'
import { useScroll, useElementSize, useThrottleFn, useAsyncState } from '@vueuse/core'
import axios from 'axios'
​
// 数据获取
const { state: LIST_DATA, isLoading, execute: getData } = useAsyncState(
  async () => {
    const { data } = await axios.get('/api/mock/68e0c49dbf906d0623008434/api/v1/test#!method=get')
    return data.data
  },
  [], // 初始值
  { immediate: false } // 不立即执行
)
​
// 虚拟列表配置
const listHeight = ref(60) // 每个列表项的高度
const showListCount = ref(10) // 可视区域显示的项目数量
const containerHeight = computed(() => showListCount.value * listHeight.value) // 容器高度
​
// DOM 引用
const scrollContainer = ref()
const listContent = ref()
​
// 使用 VueUse 的滚动监听
const { y: scrollTop } = useScroll(scrollContainer, {
  throttle: 16, // 60fps
  idle: 100
})
​
// 使用 VueUse 的元素尺寸监听
const { height: actualContainerHeight } = useElementSize(scrollContainer)
​
// 索引和偏移计算
const startIndex = computed(() => Math.floor(scrollTop.value / listHeight.value))
const endIndex = computed(() => Math.min(startIndex.value + showListCount.value + 2, LIST_DATA.value.length)) // +2 缓冲
const offsetY = computed(() => startIndex.value * listHeight.value)
​
// 显示的数据
const showData = computed(() => {
  if (!LIST_DATA.value.length) return []
  return LIST_DATA.value.slice(startIndex.value, endIndex.value)
})
​
// 总高度(用于撑开滚动条)
const totalHeight = computed(() => LIST_DATA.value.length * listHeight.value)
​
// 虚拟列表状态信息
const virtualListInfo = computed(() => ({
  total: LIST_DATA.value.length,
  visible: showData.value.length,
  startIndex: startIndex.value,
  endIndex: endIndex.value,
  scrollTop: scrollTop.value,
  progress: LIST_DATA.value.length > 0 ? ((scrollTop.value / (totalHeight.value - actualContainerHeight.value)) * 100).toFixed(1) : 0
}))
​
// 滚动到指定位置的方法
const scrollToIndex = (index) => {
  if (scrollContainer.value) {
    scrollContainer.value.scrollTop = index * listHeight.value
  }
}
​
// 滚动到顶部/底部
const scrollToTop = () => scrollToIndex(0)
const scrollToBottom = () => scrollToIndex(LIST_DATA.value.length - 1)
</script>
​
<template>
  <div class="virtual-list-container">
    <h1>VueUse 虚拟列表</h1>
    
    <!-- 控制面板 -->
    <div class="control-panel">
      <button @click="getData" class="load-btn" :disabled="isLoading">
        {{ isLoading ? '加载中...' : '获取数据' }}
      </button>
      
      <div class="info">
        <span>总数据量: {{ virtualListInfo.total }}</span>
        <span>当前显示: {{ virtualListInfo.startIndex + 1 }} - {{ virtualListInfo.endIndex }}</span>
      </div>
    </div>
​
    <!-- 虚拟列表容器 -->
    <div 
      ref="scrollContainer"
      class="virtual-list-wrapper"
      :style="{ height: containerHeight + 'px' }"
    >
      <!-- 占位元素,用于撑开滚动条 -->
      <div 
        class="virtual-list-phantom" 
        :style="{ height: totalHeight + 'px' }"
      ></div>
      
      <!-- 可视区域 -->
      <div 
        ref="listContent"
        class="virtual-list-content"
        :style="{ transform: `translateY(${offsetY}px)` }"
      >
        <div 
          v-for="(item, index) in showData" 
          :key="item.id"
          class="list-item"
          :style="{ height: listHeight + 'px' }"
        >
          <div class="item-content">
            <span class="item-title">{{ item.title }}</span>
            <span class="item-id">ID: {{ item.id }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

VueUse API 详解

useScroll 配置选项
javascript 复制代码
const { y: scrollTop } = useScroll(scrollContainer, {
  throttle: 16,    // 节流延迟(毫秒)
  idle: 100,       // 空闲检测时间
  offset: {        // 滚动偏移
    top: 0,
    bottom: 0,
    left: 0,
    right: 0
  }
})
useAsyncState 配置选项
javascript 复制代码
const { state, isLoading, error, execute } = useAsyncState(
  promiseFunction,  // 异步函数
  initialState,     // 初始状态
  {
    immediate: false,     // 是否立即执行
    resetOnExecute: true, // 执行时是否重置状态
    shallow: true,        // 是否使用浅层响应式
    throwError: false     // 是否抛出错误
  }
)

扩展功能方法

方法名 参数 返回值 说明
scrollToIndex(index) number void 滚动到指定索引位置
scrollToTop() - void 滚动到列表顶部
scrollToBottom() - void 滚动到列表底部
getData() - Promise 获取列表数据

方案三:TanStack Virtual

技术特点

TanStack Virtual 是专业的虚拟化库,具有以下优势:

  • 高性能: 专门为虚拟化场景优化

  • 灵活配置: 支持动态高度、水平滚动等

  • TypeScript支持: 完整的类型定义

  • 跨框架: 支持React、Vue、Solid等多个框架

完整代码实现

javascript 复制代码
<script setup>
import { ref, computed } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import axios from 'axios'
​
// 数据
const LIST_DATA = ref([])
const isLoading = ref(false)
​
const getData = async () => {
  isLoading.value = true
  try {
    const { data } = await axios.get('/api/mock/68e0c49dbf906d0623008434/api/v1/test#!method=get')
    LIST_DATA.value = data.data
  } catch (error) {
    console.error('获取数据失败:', error)
  } finally {
    isLoading.value = false
  }
}
​
// 容器引用
const parentRef = ref()
​
// 使用 TanStack Virtual - 修复响应式问题
const virtualizer = useVirtualizer(
  computed(() => ({
    count: LIST_DATA.value.length,
    getScrollElement: () => parentRef.value,
    estimateSize: () => 60, // 每项高度
    overscan: 5, // 缓冲项数
  }))
)
</script>
​
<template>
  <div class="container">
    <h1>TanStack Virtual 虚拟列表</h1>
    
    <div class="control-panel">
      <button @click="getData" :disabled="isLoading">
        {{ isLoading ? '加载中...' : '获取数据' }}
      </button>
      <div class="info">
        <span>总数据量: {{ LIST_DATA.length }}</span>
        <span v-if="LIST_DATA.length > 0">虚拟项目数: {{ virtualizer.getVirtualItems().length }}</span>
      </div>
    </div>
​
    <!-- 虚拟列表容器 -->
    <div
      v-if="LIST_DATA.length > 0"
      ref="parentRef"
      class="list-container"
      :style="{ height: '600px', overflow: 'auto' }"
    >
      <div
        :style="{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }"
      >
        <div
          v-for="item in virtualizer.getVirtualItems()"
          :key="item.key"
          :style="{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: `${item.size}px`,
            transform: `translateY(${item.start}px)`,
          }"
          class="list-item"
        >
          <div class="item-content">
            <span>{{ LIST_DATA[item.index]?.title || `项目 ${item.index + 1}` }}</span>
            <span class="item-id">ID: {{ LIST_DATA[item.index]?.id || item.index + 1 }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

TanStack Virtual API 详解

useVirtualizer 配置选项
javascript 复制代码
const virtualizer = useVirtualizer({
  count: 1000,                    // 总项目数量
  getScrollElement: () => parentRef.value, // 滚动容器元素
  estimateSize: () => 50,         // 估算每项高度
  overscan: 5,                    // 缓冲区项目数量
  horizontal: false,              // 是否水平滚动
  paddingStart: 0,               // 起始填充
  paddingEnd: 0,                 // 结束填充
  scrollMargin: 0,               // 滚动边距
  gap: 0,                        // 项目间距
  indexAttribute: 'data-index',   // 索引属性名
  initialOffset: 0,              // 初始偏移量
  getItemKey: (index) => index,   // 获取项目key的函数
  rangeExtractor: defaultRangeExtractor, // 范围提取器
  measureElement: undefined,      // 测量元素函数
  scrollToFn: elementScrollToFn,  // 滚动函数
})
核心方法和属性
方法/属性 类型 说明
getVirtualItems() VirtualItem[] 获取当前虚拟项目列表
getTotalSize() number 获取总的虚拟尺寸
scrollToIndex(index, options?) void 滚动到指定索引
scrollToOffset(offset, options?) void 滚动到指定偏移量
measure() void 重新测量所有项目
VirtualItem 对象结构
javascript 复制代码
interface VirtualItem {
  key: Key          // 项目唯一标识
  index: number     // 项目索引
  start: number     // 项目起始位置
  end: number       // 项目结束位置
  size: number      // 项目尺寸
}

三种方案性能对比

性能指标对比

指标 手写实现 VueUse实现 TanStack Virtual
初始化性能 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
滚动流畅度 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
内存占用 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
代码复杂度 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
可扩展性 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
学习成本 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐

适用场景分析

手写实现

适用场景:

  • 学习虚拟列表原理

  • 项目对第三方依赖有严格限制

  • 需要完全自定义的简单场景

优势:

  • 无额外依赖

  • 代码可控性强

  • 学习价值高

劣势:

  • 功能相对简单

  • 需要自己处理边界情况

  • 维护成本高

VueUse实现

适用场景:

  • Vue3项目中已使用VueUse

  • 需要响应式的滚动监听

  • 中等复杂度的虚拟列表需求

优势:

  • 组合式API风格

  • 丰富的响应式工具

  • 与Vue3生态完美集成

劣势:

  • 需要额外学习VueUse API

  • 相比专业库功能有限

TanStack Virtual

适用场景:

  • 高性能要求的大数据量列表

  • 需要复杂虚拟化功能(动态高度、水平滚动等)

  • 专业的数据展示应用

优势:

  • 专业的虚拟化解决方案

  • 性能优异

  • 功能完整

  • TypeScript支持完善

劣势:

  • 学习成本相对较高

  • 包体积相对较大

实际项目集成指南

1. 选择合适的方案

javascript 复制代码
// 根据项目需求选择方案
const chooseVirtualListSolution = (requirements) => {
  if (requirements.learningPurpose) {
    return '手写实现'
  }
  
  if (requirements.dataSize < 1000 && requirements.complexity === 'simple') {
    return 'VueUse实现'
  }
  
  if (requirements.dataSize > 5000 || requirements.complexity === 'complex') {
    return 'TanStack Virtual'
  }
  
  return 'VueUse实现' // 默认推荐
}

2. 性能优化建议

javascript 复制代码
// 通用优化策略
const optimizationTips = {
  // 1. 使用节流函数
  throttleScroll: true,
  throttleDelay: 16, // 60fps
  
  // 2. 设置合适的缓冲区
  overscan: 5,
  
  // 3. 避免在滚动时进行复杂计算
  avoidHeavyComputation: true,
  
  // 4. 使用固定高度提升性能
  useFixedHeight: true,
  
  // 5. 合理设置可视区域大小
  visibleItemCount: 10-15
}

3. 常见问题解决

问题1:滚动时出现白屏
javascript 复制代码
// 解决方案:增加缓冲区
const config = {
  overscan: 5, // 增加缓冲项目数量
  throttle: 16 // 降低节流延迟
}
问题2:动态高度支持
javascript 复制代码
// TanStack Virtual 支持动态高度
const virtualizer = useVirtualizer({
  count: data.length,
  getScrollElement: () => parentRef.value,
  estimateSize: (index) => {
    // 根据内容估算高度
    return data[index]?.content?.length > 100 ? 120 : 60
  },
  measureElement: (element) => {
    // 实际测量元素高度
    return element.getBoundingClientRect().height
  }
})

总结

本文详细介绍了三种Vue虚拟列表实现方案,每种方案都有其适用场景:

  1. 手写实现:适合学习原理和简单场景

  2. VueUse实现:适合中等复杂度的Vue3项目

  3. TanStack Virtual:适合高性能要求的专业应用

选择合适的方案需要考虑:

  • 项目规模和性能要求

  • 团队技术栈和学习成本

  • 功能复杂度和扩展需求

希望本文能帮助您在实际项目中选择和实现最适合的虚拟列表方案!

参考资源

相关推荐
Csvn32 分钟前
性能监控
前端
Highcharts.js34 分钟前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Csvn1 小时前
构建工具
前端
FRing031 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷2 小时前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
kyriewen4 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒4 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
工控丫丫4 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库
jerrywus5 小时前
我用 GPT-6.1-sol 做了台会讲解的 3D 售货机:60 罐饮料,一口没喝上
前端·agent·claude
孟陬6 小时前
Foobar 技术周刊#159:当看不懂代码时,如何高效排障调试。脏页陷阱:一条只读查询,写入数 TB 数据。推测解码:大模型加速利器
前端·人工智能·后端