Vue3虚拟滚动实战:从固定高度到动态高度,告别列表卡顿
概念:
大白话解释:列表的虚拟滚动就是 “只渲染你能看到的内容,其他的先不画出来”,目的是让长列表滚动起来不卡顿。
举个例子:
你在手机上刷一个有 10000 条内容的列表(比如朋友圈、商品列表)。如果手机一次性把这 10000 条都画在屏幕上,相当于瞬间加载 10000 张图、10000 段文字,手机内存和 CPU 肯定扛不住,会变得特别卡,甚至直接崩溃。
但虚拟滚动会这么干:
- 先算清楚你屏幕上能 “装下” 多少条内容(比如一屏能显示 10 条);
- 只加载当前屏幕里能看到的这 10 条,以及上下各一点点(比如再多加载 2 条,防止滚动太快时出现空白);
- 当你向上滑动列表时,把已经滑出屏幕顶部的内容 “删掉”,同时加载屏幕底部即将出现的新内容。
这样一来,不管列表有 1 万条还是 100 万条,手机上实际同时存在的内容永远只有十几条,内存占用极少,滚动起来就会非常流畅。
代码实现:
Vue3使用vue-virtual-scroller
安装
npm install --save vue-virtual-scroller@next
注意:vue@“^2.6.11” from vue-virtual-scroller@1.1.2
如果是vue2,则可以直接安装
npm install --save vue-virtual-scroller
场景一:固定高度虚拟滚动列表
<template><div class="container"><h3 class="title">固定高度虚拟滚动列表</h3><RecycleScrollerclass="scroller":items="items":item-size="40"key-field="id"v-slot="{ item }"><div class="item" :key="item.id">{{ item.text }}</div></RecycleScroller></div>
</template><script setup>
import { ref, onMounted } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'const items = ref([])
onMounted(() => {items.value = Array.from({ length: 10000 }).map((_, i) => ({id: i,text: `固定高度项 ${i + 1}`}))
})
</script><style scoped>
.container {height: 500px;
}.title {color: #05766e;
}
.scroller {height: 100%;overflow: auto;
}
.item {height: 40px;line-height: 40px;padding: 0 16px;border-bottom: 1px solid #eee;color: #36b5ac;
}
</style>
配置说明:
:item-size="40"指定每个项目的固定高度适用于所有项目高度一致的场景性能最优,计算复杂度最低
效果如图所示:
场景二:上拉加载虚拟滚动列表
<template><div class="container"><h3 class="title">上拉加载虚拟滚动列表</h3><RecycleScrollerclass="scroller":items="items":item-size="40"key-field="id"v-slot="{ item }"><div class="item" :key="item.id" @click="checkReachEnd">{{ item.text }}</div></RecycleScroller><div class="footer-status"><div class="loading" v-if="loading">加载中...</div><div class="no-more" v-else-if="!hasMore">没有更多数据了</div><div class="load-more" v-else @click="loadMore">点击加载更多</div></div></div>
</template><script setup>
import { ref, onMounted, nextTick } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'const page = ref(1)
const pageSize = ref(100)
const items = ref([])
const loading = ref(false)
const hasMore = ref(true)// 检查是否到达底部
const checkReachEnd = () => {nextTick(() => {const scroller = document.querySelector('.scroller')if (scroller) {const { scrollTop, scrollHeight, clientHeight } = scrollerif (scrollTop + clientHeight >= scrollHeight - 100) {loadMore()}}})
}const loadMore = async () => {if (loading.value || !hasMore.value) returnloading.value = true// 模拟异步加载数据await new Promise(resolve => setTimeout(resolve, 500))const newItems = Array.from({ length: pageSize.value }).map((_, i) => ({id: (page.value - 1) * pageSize.value + i,text: `上拉加载项 ${(page.value - 1) * pageSize.value + i + 1}`}))items.value = [...items.value, ...newItems]page.value++// 模拟数据加载完毕if (page.value > 10) {hasMore.value = false}loading.value = false
}onMounted(() => {loadMore()
})
</script><style scoped>
.container {height: 500px;display: flex;flex-direction: column;
}.title {color: #05766e;margin: 0 0 16px 0;flex-shrink: 0;
} .scroller {flex: 1;overflow: auto;border: 1px solid #e0e0e0;border-radius: 8px;
}.item {height: 40px;line-height: 40px;padding: 0 16px;border-bottom: 1px solid #eee;color: #36b5ac;background: #fff;cursor: pointer;transition: background-color 0.2s;
}.item:hover {background-color: #f5f5f5;
}.item:last-child {border-bottom: none;
}.footer-status {flex-shrink: 0;margin-top: 16px;
}.loading, .no-more, .load-more {text-align: center;padding: 16px;color: #666;border-radius: 4px;background: #f9f9f9;
}.load-more {cursor: pointer;color: #36b5ac;border: 1px solid #36b5ac;transition: all 0.2s;
}.load-more:hover {background: #36b5ac;color: white;
}.loading {background: #e3f2fd;color: #1976d2;
}.no-more {background: #f3e5f5;color: #7b1fa2;
}
</style>
配置说明:
checkReachEnd检测是否到达底部通过loading和hasMore状态控制加载逻辑动态添加数据到列表中
效果如图所示:
场景三:动态高度虚拟滚动列表
<template><div class="container"><h3 class="title">动态高度虚拟滚动列表</h3><div class="controls"><el-button @click="addRandomItem" type="primary" size="small">添加随机项</el-button><el-button @click="clearItems" type="danger" size="small">清空列表</el-button><span class="item-count">总计: {{ items.length }} 项</span></div><DynamicScrollerclass="scroller":items="items":min-item-size="60"key-field="id"v-slot="{ item, index, active }"><DynamicScrollerItem:item="item":active="active":size-dependencies="[item.title,item.content,item.tags]":data-index="index":data-active="active"><div class="item" :class="{ 'item-active': active }"><div class="item-header"><div class="item-avatar" :style="{ backgroundColor: item.color }">{{ item.title.charAt(0) }}</div><div class="item-info"><h4 class="item-title">{{ item.title }}</h4><span class="item-time">{{ item.time }}</span></div><div class="item-actions"><el-button size="small" type="text" @click="toggleExpand(item.id)">{{ item.expanded ? '收起' : '展开' }}</el-button><el-button size="small" type="text" @click="deleteItem(item.id)"style="color: #f56c6c">删除</el-button></div></div><div class="item-content"><p>{{ item.content }}</p><div v-if="item.expanded" class="item-details"><div class="detail-section"><strong>详细描述:</strong><p>{{ item.description }}</p></div><div class="detail-section" v-if="item.metadata"><strong>元数据:</strong><ul><li v-for="(value, key) in item.metadata" :key="key"><strong>{{ key }}:</strong> {{ value }}</li></ul></div></div></div><div class="item-tags" v-if="item.tags && item.tags.length"><el-tag v-for="tag in item.tags" :key="tag" size="small" :type="getTagType(tag)"class="tag">{{ tag }}</el-tag></div></div></DynamicScrollerItem></DynamicScroller></div>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import { ElButton, ElTag } from 'element-plus'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'interface ListItem {id: numbertitle: stringcontent: stringdescription: stringtime: stringcolor: stringexpanded: booleantags: string[]metadata?: Record<string, any>
}const items = ref<ListItem[]>([])// 生成随机颜色
const getRandomColor = () => {const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9C27B0', '#FF9800', '#4CAF50','#2196F3', '#FF5722', '#795548', '#607D8B']return colors[Math.floor(Math.random() * colors.length)]
}// 生成随机标签
const getRandomTags = () => {const allTags = ['重要', '紧急', '待办', '已完成', '进行中', '暂停', '高优先级', '低优先级', '新功能', '修复']const tagCount = Math.floor(Math.random() * 4) + 1const shuffled = allTags.sort(() => 0.5 - Math.random())return shuffled.slice(0, tagCount)
}// 生成随机内容
const getRandomContent = () => {const contents = ['这是一个简短的内容项目。','这是一个中等长度的内容项目,包含更多的文字描述和详细信息。','这是一个很长的内容项目,包含大量的文字描述、详细信息、使用说明、注意事项等等。这种类型的内容通常需要更多的垂直空间来显示,因此非常适合用来测试动态高度的虚拟滚动功能。','简单项目','包含多行文本的复杂项目\n第二行内容\n第三行内容','这个项目有很多详细信息需要展示,包括但不限于:功能描述、技术规格、使用方法、注意事项、更新日志等等内容。']return contents[Math.floor(Math.random() * contents.length)]
}// 生成随机描述
const getRandomDescription = () => {const descriptions = ['这是详细描述部分,提供了更多关于此项目的信息。','详细描述:此项目包含复杂的业务逻辑和多个功能模块,需要仔细处理各种边界情况。','项目详情:这是一个重要的功能模块,涉及用户交互、数据处理、状态管理等多个方面。','补充说明:该功能已经过充分测试,可以在生产环境中稳定运行。']return descriptions[Math.floor(Math.random() * descriptions.length)]
}// 初始化数据
const initializeData = () => {items.value = Array.from({ length: 1000 }).map((_, i) => ({id: i + 1,title: `动态项目 ${i + 1}`,content: getRandomContent(),description: getRandomDescription(),time: new Date(Date.now() - Math.random() * 10000000000).toLocaleString(),color: getRandomColor(),expanded: false,tags: getRandomTags(),metadata: Math.random() > 0.5 ? {'创建者': `用户${Math.floor(Math.random() * 100)}`,'状态': ['进行中', '已完成', '待审核'][Math.floor(Math.random() * 3)],'优先级': ['高', '中', '低'][Math.floor(Math.random() * 3)]} : undefined}))
}// 切换展开状态
const toggleExpand = (id: number) => {const item = items.value.find(item => item.id === id)if (item) {item.expanded = !item.expanded}
}// 删除项目
const deleteItem = (id: number) => {const index = items.value.findIndex(item => item.id === id)if (index > -1) {items.value.splice(index, 1)}
}// 添加随机项目
const addRandomItem = () => {const newId = Math.max(...items.value.map(item => item.id), 0) + 1const newItem: ListItem = {id: newId,title: `新项目 ${newId}`,content: getRandomContent(),description: getRandomDescription(),time: new Date().toLocaleString(),color: getRandomColor(),expanded: false,tags: getRandomTags(),metadata: {'创建者': '当前用户','状态': '新建','优先级': '中'}}items.value.unshift(newItem)
}// 清空列表
const clearItems = () => {items.value = []
}// 获取标签类型
const getTagType = (tag: string) => {const typeMap: Record<string, string> = {'重要': 'danger','紧急': 'danger','已完成': 'success','进行中': 'primary','暂停': 'warning','高优先级': 'danger','低优先级': 'info'}return typeMap[tag] || ''
}onMounted(() => {initializeData()
})
</script><style scoped>
.container {height: 600px;width: 400px;display: flex;flex-direction: column;padding: 20px;background: #f5f7fa;border-radius: 8px;
}.title {color: #303133;margin: 0 0 16px 0;font-size: 20px;font-weight: 600;
}.controls {display: flex;align-items: center;gap: 12px;margin-bottom: 16px;padding: 12px;background: white;border-radius: 6px;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}.item-count {margin-left: auto;color: #606266;font-size: 14px;
}.scroller {flex: 1;background: white;border-radius: 8px;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);overflow: hidden;
}.item {padding: 16px;border-bottom: 1px solid #f0f0f0;background: white;transition: all 0.2s ease;
}.item:hover {background: #fafafa;
}.item:last-child {border-bottom: none;
}.item-active {background: #f0f9ff;
}.item-header {display: flex;align-items: flex-start;gap: 12px;margin-bottom: 8px;
}.item-avatar {width: 40px;height: 40px;border-radius: 50%;display: flex;align-items: center;justify-content: center;color: white;font-weight: bold;font-size: 16px;flex-shrink: 0;
}.item-info {flex: 1;min-width: 0;
}.item-title {margin: 0 0 4px 0;font-size: 16px;font-weight: 600;color: #303133;line-height: 1.4;
}.item-time {font-size: 12px;color: #909399;
}.item-actions {display: flex;gap: 8px;flex-shrink: 0;
}.item-content {margin-left: 52px;color: #606266;line-height: 1.6;
}.item-content p {margin: 0 0 8px 0;white-space: pre-line;
}.item-details {margin-top: 12px;padding: 12px;background: #f8f9fa;border-radius: 6px;border-left: 3px solid #409eff;
}.detail-section {margin-bottom: 12px;
}.detail-section:last-child {margin-bottom: 0;
}.detail-section strong {color: #303133;display: block;margin-bottom: 4px;
}.detail-section p {margin: 0;color: #606266;
}.detail-section ul {margin: 0;padding-left: 16px;color: #606266;
}.detail-section li {margin-bottom: 4px;
}.item-tags {margin-left: 52px;margin-top: 8px;display: flex;flex-wrap: wrap;gap: 6px;
}.tag {font-size: 12px;
}/* 虚拟滚动器样式 */
:deep(.vue-recycle-scroller) {height: 100%;
}:deep(.vue-recycle-scroller__item-wrapper) {box-sizing: border-box;
}:deep(.vue-recycle-scroller__item-view) {box-sizing: border-box;
}/* 响应式设计 */
@media (max-width: 768px) {.container {padding: 12px;height: 500px;}.item {padding: 12px;}.item-header {flex-direction: column;gap: 8px;}.item-actions {align-self: flex-start;}.item-content,.item-tags {margin-left: 0;}.controls {flex-wrap: wrap;gap: 8px;}.item-count {margin-left: 0;width: 100%;text-align: center;}
}
</style>
配置说明:
使用DynamicScroller和DynamicScrollerItem自动计算每个项目的实际高度高性能渲染大量数据(1000+数据)虚拟滚动:只渲染可见区域的项目尺寸依赖:正确跟踪影响高度的属性变化最小高度:设置合理的最小项目高度
效果如图所示:
常见的场景:电商的商品列表、聊天记录、长文档分页等,基本都是用虚拟滚动来优化体验。