Vue 虚拟列表实现方案详解:三种方法的完整对比与实践
前言
在现代Web开发中,当需要渲染大量数据列表时,传统的DOM渲染方式会导致严重的性能问题。虚拟列表(Virtual List)技术通过只渲染可视区域内的元素,大大提升了大数据量列表的渲染性能。
本文将详细介绍三种Vue虚拟列表的实现方案:
-
手写原理实现 - 深入理解虚拟列表核心原理
-
VueUse库实现 - 利用组合式API的强大功能
-
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 配置
{"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()
定位可视区域 -
占位元素:维持正确的滚动条总高度
完整代码实现
<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: 内置节流函数
完整代码实现
<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, // 60fpsidle: 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 配置选项
const { y: scrollTop } = useScroll(scrollContainer, {throttle: 16, // 节流延迟(毫秒)idle: 100, // 空闲检测时间offset: { // 滚动偏移top: 0,bottom: 0,left: 0,right: 0}
})
useAsyncState 配置选项
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等多个框架
完整代码实现
<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 = truetry {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>
<!-- 虚拟列表容器 --><divv-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',}"><divv-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 配置选项
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 对象结构
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. 选择合适的方案
// 根据项目需求选择方案
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. 性能优化建议
// 通用优化策略
const optimizationTips = {// 1. 使用节流函数throttleScroll: true,throttleDelay: 16, // 60fps// 2. 设置合适的缓冲区overscan: 5,// 3. 避免在滚动时进行复杂计算avoidHeavyComputation: true,// 4. 使用固定高度提升性能useFixedHeight: true,// 5. 合理设置可视区域大小visibleItemCount: 10-15
}
3. 常见问题解决
问题1:滚动时出现白屏
// 解决方案:增加缓冲区
const config = {overscan: 5, // 增加缓冲项目数量throttle: 16 // 降低节流延迟
}
问题2:动态高度支持
// 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虚拟列表实现方案,每种方案都有其适用场景:
-
手写实现:适合学习原理和简单场景
-
VueUse实现:适合中等复杂度的Vue3项目
-
TanStack Virtual:适合高性能要求的专业应用
选择合适的方案需要考虑:
-
项目规模和性能要求
-
团队技术栈和学习成本
-
功能复杂度和扩展需求
希望本文能帮助您在实际项目中选择和实现最适合的虚拟列表方案!
参考资源
-
Vue3 官方文档
-
VueUse 官方文档
-
TanStack Virtual 官方文档
-
虚拟列表原理详解