本文手把手教你用Vue3实现高性能虚拟滚动,解决大数据量渲染卡顿问题!
前言:为什么需要虚拟滚动?
在日常开发中,我们经常会遇到需要渲染大量数据的场景,比如聊天记录、商品列表、日志展示等。当数据量达到成千上万条时,直接渲染所有DOM节点会导致:
- ⚠️ 页面卡顿、滚动不流畅
- ⚠️ 内存占用过高,甚至浏览器崩溃
- ⚠️ 用户体验极差
虚拟滚动正是解决这一痛点的银弹!它通过"视觉欺骗"技术,只渲染可视区域的内容,让万级数据列表也能丝滑滚动!
核心原理:视觉欺骗的艺术
虚拟滚动的核心思想很简单:只渲染你看得见的部分!
想象一下,你通过一个固定高度的窗口看一幅很长的画卷:
- 你只能看到窗口范围内的部分
- 当画卷上下移动时,窗口内的内容发生变化
- 但整幅画卷的实际长度保持不变
虚拟滚动就是这样工作的:
// 伪代码理解
实际数据: [item1, item2, item3, ..., item10000]
可视区域: 只能显示10条数据
滚动前: 显示 item1 到 item10
滚动后: 显示 item50 到 item60
实战:手把手实现Vue3虚拟滚动
下面我们用Vue3 + TypeScript实现一个完整的虚拟滚动组件!
1. 首先创建虚拟滚动Hook
// hooks/useVirtualScroll.ts
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
interface VirtualScrollOptions {
data: any[] // 数据源
itemHeight: number // 每项高度(固定高度方案)
containerRef: Ref<HTMLElement | null> // 容器ref
overscan?: number // 上下预渲染数量(滚动更顺滑)
}
export function useVirtualScroll(options: VirtualScrollOptions) {
const { data, itemHeight, containerRef, overscan = 5 } = options
// 当前滚动位置
const scrollTop = ref(0)
// 容器高度(动态获取)
const containerHeight = ref(0)
// 总高度(用于撑开容器)
const totalHeight = computed(() => data.length * itemHeight)
// 可见区域起始索引
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / itemHeight) - overscan)
})
// 可见区域结束索引
const endIndex = computed(() => {
const visibleCount = Math.ceil(containerHeight.value / itemHeight)
return Math.min(
data.length - 1,
startIndex.value + visibleCount + overscan * 2
)
})
// 可视区域数据
const visibleData = computed(() => {
return data.slice(startIndex.value, endIndex.value + 1)
})
// 上方占位高度
const offsetTop = computed(() => startIndex.value * itemHeight)
// 下方占位高度
const offsetBottom = computed(() => {
return totalHeight.value - offsetTop.value - visibleData.value.length * itemHeight
})
// 滚动处理
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
// 初始化容器高度
const updateContainerHeight = () => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
}
}
// 监听窗口大小变化
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
updateContainerHeight()
// 监听容器大小变化
if (containerRef.value) {
resizeObserver = new ResizeObserver(updateContainerHeight)
resizeObserver.observe(containerRef.value)
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
return {
scrollTop,
visibleData,
offsetTop,
offsetBottom,
totalHeight,
handleScroll
}
}
2. 创建虚拟滚动组件
<!-- components/VirtualScroll.vue -->
<template>
<div
class="virtual-scroll-container"
ref="containerRef"
@scroll="handleScroll"
>
<div
class="virtual-scroll-wrapper"
:style="{ height: `${totalHeight}px` }"
>
<!-- 上方占位 -->
<div :style="{ height: `${offsetTop}px` }"></div>
<!-- 可视区域的内容 -->
<div
v-for="item in visibleData"
:key="getItemKey(item)"
class="virtual-item"
:style="{ height: `${itemHeight}px` }"
>
<slot :item="item" :index="item.__originalIndex"></slot>
</div>
<!-- 下方占位 -->
<div :style="{ height: `${offsetBottom}px` }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualScroll } from '../hooks/useVirtualScroll'
interface Props {
data: any[] // 数据源
itemHeight: number // 每项高度
itemKey?: string | ((item: any) => string | number) // 项的唯一标识
overscan?: number // 预渲染数量
}
const props = withDefaults(defineProps<Props>(), {
itemKey: 'id',
overscan: 5
})
// 容器ref
const containerRef = ref<HTMLElement | null>(null)
// 为原始数据添加索引(用于slot传参)
const processedData = ref(
props.data.map((item, index) => ({
...item,
__originalIndex: index
}))
)
// 使用虚拟滚动hook
const {
visibleData,
offsetTop,
offsetBottom,
totalHeight,
handleScroll
} = useVirtualScroll({
data: processedData.value,
itemHeight: props.itemHeight,
containerRef,
overscan: props.overscan
})
// 获取项的唯一key
const getItemKey = (item: any) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item)
}
return item[props.itemKey]
}
// 暴露方法给父组件
defineExpose({
scrollToTop: () => {
if (containerRef.value) {
containerRef.value.scrollTop = 0
}
},
scrollToIndex: (index: number) => {
if (containerRef.value) {
containerRef.value.scrollTop = index * props.itemHeight
}
}
})
</script>
<style scoped lang="scss">
.virtual-scroll-container {
height: 100%;
overflow-y: auto;
position: relative;
.virtual-scroll-wrapper {
position: relative;
.virtual-item {
position: absolute;
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid #eee;
// 添加hover效果
&:hover {
background-color: #f5f5f5;
}
}
}
}
</style>
3. 使用示例
<!-- App.vue -->
<template>
<div class="app">
<h1>虚拟滚动演示 - 10000条数据</h1>
<div class="controls">
<button @click="addItems">添加100条数据</button>
<button @click="scrollToTop">滚动到顶部</button>
<button @click="scrollTo5000">滚动到第5000项</button>
<span>当前数据量: {{ data.length }}</span>
</div>
<VirtualScroll
:data="data"
:item-height="60"
item-key="id"
:overscan="10"
ref="virtualScrollRef"
>
<template #default="{ item, index }">
<div class="item-content">
<div class="item-index">#{{ index }}</div>
<div class="item-info">
<h3>{{ item.name }}</h3>
<p>{{ item.description }}</p>
</div>
<div class="item-actions">
<button @click="editItem(item)">编辑</button>
<button @click="deleteItem(item.id)">删除</button>
</div>
</div>
</template>
</VirtualScroll>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import VirtualScroll from './components/VirtualScroll.vue'
// 生成模拟数据
const generateData = (count: number) => {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
name: `项目 ${index + 1}`,
description: `这是第 ${index + 1} 个项目的描述信息,用于演示虚拟滚动效果。`,
value: Math.random() * 1000
}))
}
const data = ref<any[]>([])
const virtualScrollRef = ref<InstanceType<typeof VirtualScroll>>()
// 初始化数据
onMounted(() => {
data.value = generateData(10000)
})
// 添加数据
const addItems = () => {
const newData = generateData(100)
data.value.push(...newData)
}
// 编辑项目
const editItem = (item: any) => {
console.log('编辑项目:', item)
}
// 删除项目
const deleteItem = (id: number) => {
const index = data.value.findIndex(item => item.id === id)
if (index !== -1) {
data.value.splice(index, 1)
}
}
// 滚动到顶部
const scrollToTop = () => {
virtualScrollRef.value?.scrollToTop()
}
// 滚动到第5000项
const scrollTo5000 = () => {
virtualScrollRef.value?.scrollToIndex(5000)
}
</script>
<style scoped lang="scss">
.app {
height: 100vh;
display: flex;
flex-direction: column;
h1 {
text-align: center;
padding: 20px;
margin: 0;
background: #f0f0f0;
}
.controls {
padding: 15px;
background: #e0e0e0;
display: flex;
gap: 10px;
align-items: center;
button {
padding: 8px 16px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f5f5f5;
}
}
}
// VirtualScroll组件会占据剩余空间
:deep(.virtual-scroll-container) {
flex: 1;
}
}
.item-content {
display: flex;
align-items: center;
padding: 10px;
height: 100%;
.item-index {
width: 60px;
font-weight: bold;
color: #666;
}
.item-info {
flex: 1;
h3 {
margin: 0 0 5px 0;
font-size: 16px;
}
p {
margin: 0;
color: #888;
font-size: 14px;
}
}
.item-actions {
display: flex;
gap: 5px;
button {
padding: 5px 10px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 3px;
font-size: 12px;
&:hover {
background: #f0f0f0;
}
}
}
}
</style>
进阶优化:支持动态高度
上面的实现是基于固定高度的,但实际项目中经常遇到高度不固定的情况。这里提供动态高度的思路:
// 动态高度虚拟滚动思路
interface DynamicVirtualScrollOptions {
data: any[]
estimatedHeight: number // 预估高度
containerRef: Ref<HTMLElement | null>
}
// 核心变化:
// 1. 需要记录每个项目的实际高度
// 2. 计算总高度时使用实际高度累加
// 3. 通过ResizeObserver监听每个项目的高度变化
// 4. 高度变化时重新计算布局
性能对比
| 方案 | 1000条数据 | 10000条数据 | 内存占用 |
|---|---|---|---|
| 传统渲染 | 轻微卡顿 | 严重卡顿 | 高 |
| 虚拟滚动 | 流畅 | 流畅 | 低 |
总结
虚拟滚动通过巧妙的"视觉欺骗"技术,解决了大数据量渲染的性能瓶颈。本文实现的Vue3虚拟滚动组件具有以下特点:
✅ 高性能:只渲染可视区域,万级数据无压力
✅ 易用性:提供简洁的API和灵活的插槽
✅ 类型安全:完整TypeScript支持
✅ 可扩展:支持动态高度、滚动控制等进阶功能
直接复制上面的代码,亲自尝试一下你就能拥有一个生产级的虚拟滚动组件!
进一步学习
如果你想深入了解虚拟滚动的更多细节和优化技巧,可以研究:
- 动态高度计算优化
- 滚动节流和防抖
- 浏览器兼容性处理
- 移动端适配
希望这篇文章对你有所帮助!如果有任何问题,欢迎在评论区讨论~