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:适合高性能要求的专业应用

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

  • 项目规模和性能要求

  • 团队技术栈和学习成本

  • 功能复杂度和扩展需求

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

参考资源

相关推荐
white-persist2 小时前
Burp Suite模拟器抓包全攻略
前端·网络·安全·web安全·notepad++·原型模式
ObjectX前端实验室2 小时前
【前端工程化】脚手架篇 - 模板引擎 & 动态依赖管理脚手架
前端
GISer_Jing2 小时前
前端GIS篇——WebGIS、WebGL、Java后端篇
java·前端·webgl
excel3 小时前
Vue3 EffectScope 源码解析与理解
前端·javascript·面试
细节控菜鸡3 小时前
【2025最新】ArcGIS for JS 实现地图卷帘效果
开发语言·javascript·arcgis
不老刘4 小时前
Base UI:一款极简主义的「无样式」组件库
前端·ui
祈祷苍天赐我java之术4 小时前
Redis 有序集合解析
java·前端·windows·redis·缓存·bootstrap·html
ObjectX前端实验室5 小时前
【react18原理探究实践】React Effect List 构建与 Commit 阶段详解
前端·react.js
用户1456775610375 小时前
文件太大传不了?用它一压,秒变合格尺寸!
前端