虚拟滚动
处理大量数据列表的虚拟滚动解决方案。
@vueuse/core (useVirtualList)
基本信息
- 简介: VueUse 提供的虚拟列表组合式函数
- 链接: https://vueuse.org/core/useVirtualList/
- GitHub: https://github.com/vueuse/vueuse
特点
- ✅ 轻量级
- ✅ 响应式
- ✅ TypeScript 支持
- ✅ 组合式 API
- ✅ 灵活配置
安装
bash
npm install @vueuse/corebash
pnpm add @vueuse/corebash
yarn add @vueuse/corebash
bun add @vueuse/core基础用法
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'
// 生成大量数据
const allItems = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
}))
const { list, containerProps, wrapperProps } = useVirtualList(
allItems,
{
itemHeight: 50, // 每项高度
overscan: 10 // 预渲染项数
}
)
</script>
<template>
<div v-bind="containerProps" class="h-400px overflow-auto">
<div v-bind="wrapperProps">
<div
v-for="item in list"
:key="item.data.id"
class="h-50px flex items-center px-4 border-b border-gray-200"
>
{{ item.data.text }}
</div>
</div>
</div>
</template>动态高度
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'
const allItems = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
height: Math.floor(Math.random() * 100) + 50 // 随机高度
}))
const { list, containerProps, wrapperProps } = useVirtualList(
allItems,
{
itemHeight: (item) => item.height, // 动态高度
overscan: 5
}
)
</script>
<template>
<div v-bind="containerProps" class="h-400px overflow-auto">
<div v-bind="wrapperProps">
<div
v-for="item in list"
:key="item.data.id"
:style="{ height: `${item.data.height}px` }"
class="py-2 px-4 border-b border-gray-200"
>
{{ item.data.text }}
</div>
</div>
</div>
</template>水平滚动
vue
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const allItems = Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i}`
}))
const { list, containerProps, wrapperProps } = useVirtualList(
allItems,
{
itemWidth: 200, // 使用 itemWidth 而不是 itemHeight
overscan: 5
}
)
</script>
<template>
<div v-bind="containerProps" class="w-800px overflow-x-auto whitespace-nowrap">
<div v-bind="wrapperProps" class="inline-flex">
<div
v-for="item in list"
:key="item.data.id"
class="w-200px h-100px inline-flex items-center justify-center border-r border-gray-200"
>
{{ item.data.text }}
</div>
</div>
</div>
</template>组件用法(UseVirtualList)
VueUse 提供了无渲染组件版本,通过 @vueuse/components 包使用。
安装
bash
npm install @vueuse/componentsbash
pnpm add @vueuse/componentsbash
yarn add @vueuse/componentsbash
bun add @vueuse/components基础用法
vue
<script setup lang="ts">
import { ref } from 'vue'
import { UseVirtualList } from '@vueuse/components'
const list = ref(Array.from({ length: 10000 }, (_, i) => `Item ${i}`))
const options = {
itemHeight: 22,
overscan: 10
}
</script>
<template>
<UseVirtualList :list="list" :options="options" height="300px">
<template #default="{ data, index }">
<div class="h-22px px-4 flex items-center">
Row {{ index }}: {{ data }}
</div>
</template>
</UseVirtualList>
</template>滚动到指定位置
vue
<script setup lang="ts">
import { ref } from 'vue'
import { UseVirtualList } from '@vueuse/components'
const list = ref(Array.from({ length: 10000 }, (_, i) => `Item ${i}`))
const virtualListRef = ref()
const scrollToIndex = (index: number) => {
virtualListRef.value?.scrollTo(index)
}
</script>
<template>
<div>
<div class="mb-4 space-x-2">
<button @click="scrollToIndex(0)" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">滚动到顶部</button>
<button @click="scrollToIndex(5000)" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">滚动到中间</button>
<button @click="scrollToIndex(9999)" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">滚动到底部</button>
</div>
<UseVirtualList
ref="virtualListRef"
:list="list"
:options="{ itemHeight: 50 }"
height="400px"
>
<template #default="{ data, index }">
<div class="h-50px px-4 flex items-center border-b border-gray-200">
{{ index }}: {{ data }}
</div>
</template>
</UseVirtualList>
</div>
</template>复杂数据示例
点击查看完整代码
vue
<script setup lang="ts">
import { ref } from 'vue'
import { UseVirtualList } from '@vueuse/components'
interface User {
id: number
name: string
email: string
status: 'online' | 'offline'
}
const users = ref<User[]>(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
status: i % 3 === 0 ? 'online' : 'offline'
}))
)
const options = {
itemHeight: 60,
overscan: 5
}
</script>
<template>
<UseVirtualList :list="users" :options="options" height="500px">
<template #default="{ data: user }">
<div class="h-60px flex items-center px-4 gap-3 border-b border-gray-100 hover:bg-gray-50">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white flex items-center justify-center font-bold">
{{ user.name.charAt(0) }}
</div>
<div class="flex-1">
<div class="font-semibold text-gray-800 mb-1">{{ user.name }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</div>
<div
class="px-3 py-1 rounded-xl text-xs font-medium"
:class="user.status === 'online'
? 'bg-emerald-100 text-emerald-700'
: 'bg-red-100 text-red-700'"
>
{{ user.status }}
</div>
</div>
</template>
</UseVirtualList>
</template>动态高度
点击查看完整代码
vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { UseVirtualList } from '@vueuse/components'
interface Post {
id: number
title: string
content: string
author: string
}
const posts = ref<Post[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: i,
title: `Post Title ${i}`,
content: `Content ${i} `.repeat(Math.floor(Math.random() * 10) + 1),
author: `Author ${i % 10}`
}))
)
const options = computed(() => ({
itemHeight: (item: Post) => {
// 根据内容长度动态计算高度
const baseHeight = 80
const contentLines = Math.ceil(item.content.length / 60)
return baseHeight + contentLines * 20
},
overscan: 3
}))
</script>
<template>
<UseVirtualList :list="posts" :options="options" height="600px">
<template #default="{ data: post }">
<div class="p-4 border-b border-gray-200 hover:bg-gray-50">
<h3 class="m-0 mb-2 text-lg font-semibold text-gray-900">{{ post.title }}</h3>
<p class="m-0 mb-2 text-gray-600 leading-relaxed">{{ post.content }}</p>
<div class="text-sm text-gray-400">by {{ post.author }}</div>
</div>
</template>
</UseVirtualList>
</template>虚拟列表 + 无限滚动
结合 useVirtualList 和 useInfiniteScroll 实现高性能的无限加载列表。
点击查看完整代码
vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useVirtualList, useInfiniteScroll } from '@vueuse/core'
interface Item {
id: number
title: string
description: string
timestamp: string
}
// 初始数据
const allItems = ref<Item[]>(
Array.from({ length: 50 }, (_, i) => ({
id: i,
title: `Item ${i}`,
description: `This is the description for item ${i}`,
timestamp: new Date(Date.now() - Math.random() * 10000000000).toLocaleString()
}))
)
const loading = ref(false)
const hasMore = ref(true)
// 虚拟列表配置
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(
allItems,
{
itemHeight: 80,
overscan: 5
}
)
// 加载更多数据
const loadMore = async () => {
loading.value = true
// 模拟 API 请求
await new Promise(resolve => setTimeout(resolve, 1000))
const currentLength = allItems.value.length
const newItems = Array.from({ length: 20 }, (_, i) => ({
id: currentLength + i,
title: `Item ${currentLength + i}`,
description: `This is the description for item ${currentLength + i}`,
timestamp: new Date(Date.now() - Math.random() * 10000000000).toLocaleString()
}))
allItems.value.push(...newItems)
loading.value = false
// 模拟数据加载完毕
if (allItems.value.length >= 200) {
hasMore.value = false
}
}
// 获取容器元素
const containerRef = computed(() => containerProps.ref)
// 配置无限滚动
useInfiniteScroll(
containerRef,
loadMore,
{
distance: 100, // 距离底部 100px 时触发
interval: 1000, // 节流间隔
canLoadMore: () => !loading.value && hasMore.value // 控制是否可以加载
}
)
// 滚动到顶部
const scrollToTop = () => {
scrollTo(0)
}
</script>
<template>
<div class="space-y-4">
<!-- 控制按钮 -->
<div class="flex gap-2">
<button
@click="scrollToTop"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
>
回到顶部
</button>
<div class="flex items-center gap-2 text-sm text-gray-600">
<span>总数: {{ allItems.length }}</span>
<span v-if="!hasMore" class="text-orange-600">· 已加载全部</span>
</div>
</div>
<!-- 虚拟列表容器 -->
<div
v-bind="containerProps"
class="h-500px overflow-auto border border-gray-200 rounded-lg"
>
<div v-bind="wrapperProps">
<div
v-for="item in list"
:key="item.data.id"
class="h-80px flex items-center px-4 border-b border-gray-100 hover:bg-gray-50 transition"
>
<div class="flex-1">
<h3 class="m-0 mb-1 text-base font-semibold text-gray-900">
{{ item.data.title }}
</h3>
<p class="m-0 mb-1 text-sm text-gray-600">
{{ item.data.description }}
</p>
<span class="text-xs text-gray-400">
{{ item.data.timestamp }}
</span>
</div>
<div class="text-sm text-gray-400">
#{{ item.data.id }}
</div>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="h-60px flex items-center justify-center text-gray-500"
>
<div class="flex items-center gap-2">
<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span>加载中...</span>
</div>
</div>
<!-- 无更多数据 -->
<div
v-else-if="!hasMore"
class="h-60px flex items-center justify-center text-gray-400"
>
没有更多数据了
</div>
</div>
</div>
</template>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>关键要点:
- 数据管理:使用
ref存储所有数据,useVirtualList自动处理可见项 - 无限滚动:通过
useInfiniteScroll监听容器滚动,触发加载 - 性能优化:
- 设置合理的
distance值(距离底部多远触发) - 使用
interval节流避免频繁触发 - 虚拟滚动只渲染可见项,保持高性能
- 设置合理的
- 用户体验:
- 显示加载状态
- 提供回到顶部功能
- 显示数据总数和加载完成提示
vue-virtual-scroller
基本信息
- 简介: 功能强大的 Vue 虚拟滚动组件
- 链接: https://github.com/Akryum/vue-virtual-scroller
- npm:
vue-virtual-scroller
特点
- ✅ 高性能
- ✅ 动态高度
- ✅ 多种模式
- ✅ 网格布局
- ✅ 丰富的配置选项
安装
bash
npm install vue-virtual-scrollerbash
pnpm add vue-virtual-scrollerbash
yarn add vue-virtual-scrollerbash
bun add vue-virtual-scroller全局注册
typescript
// main.ts
import { createApp } from 'vue'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import App from './App.vue'
const app = createApp(App)
app.use(VueVirtualScroller)
app.mount('#app')基础用法(RecycleScroller)
vue
<script setup lang="ts">
import { ref } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Item {
id: number
text: string
}
const items = ref<Item[]>(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
}))
)
</script>
<template>
<RecycleScroller
:items="items"
:item-size="50"
key-field="id"
class="h-400px"
>
<template #default="{ item }">
<div class="h-50px flex items-center px-4 border-b border-gray-200">
{{ item.text }}
</div>
</template>
</RecycleScroller>
</template>ts
declare module 'vue-virtual-scroller' {
import {
type ObjectEmitsOptions,
type PublicProps,
type SetupContext,
type SlotsType,
type VNode,
} from 'vue';
interface RecycleScrollerProps<T> {
items: readonly T[];
direction?: 'vertical' | 'horizontal';
itemSize?: number | null;
gridItems?: number;
itemSecondarySize?: number;
minItemSize?: number;
sizeField?: string;
typeField?: string;
keyField?: keyof T;
pageMode?: boolean;
prerender?: number;
buffer?: number;
emitUpdate?: boolean;
updateInterval?: number;
listClass?: string;
itemClass?: string;
listTag?: string;
itemTag?: string;
}
interface DynamicScrollerProps<T> extends RecycleScrollerProps<T> {
minItemSize: number;
}
interface RecycleScrollerEmitOptions extends ObjectEmitsOptions {
resize: () => void;
visible: () => void;
hidden: () => void;
update: (
startIndex: number,
endIndex: number,
visibleStartIndex: number,
visibleEndIndex: number,
) => void;
'scroll-start': () => void;
'scroll-end': () => void;
}
interface RecycleScrollerSlotProps<T> {
item: T;
index: number;
active: boolean;
}
interface RecycleScrollerSlots<T> {
default(slotProps: RecycleScrollerSlotProps<T>): unknown;
before(): unknown;
empty(): unknown;
after(): unknown;
}
export interface RecycleScrollerInstance {
getScroll(): { start: number; end: number };
scrollToItem(index: number): void;
scrollToPosition(position: number): void;
}
export const RecycleScroller: <T>(
props: RecycleScrollerProps<T> & PublicProps,
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
expose?: (exposed: RecycleScrollerInstance) => void,
) => VNode & {
__ctx?: {
props: RecycleScrollerProps<T> & PublicProps;
expose(exposed: RecycleScrollerInstance): void;
slots: RecycleScrollerSlots<T>;
};
};
export const DynamicScroller: <T>(
props: DynamicScrollerProps<T> & PublicProps,
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
expose?: (exposed: RecycleScrollerInstance) => void,
) => VNode & {
__ctx?: {
props: DynamicScrollerProps<T> & PublicProps;
expose(exposed: RecycleScrollerInstance): void;
slots: RecycleScrollerSlots<T>;
};
};
interface DynamicScrollerItemProps<T> {
item: T;
active: boolean;
sizeDependencies?: unknown[];
watchData?: boolean;
tag?: string;
emitResize?: boolean;
onResize?: () => void;
}
interface DynamicScrollerItemEmitOptions extends ObjectEmitsOptions {
resize: () => void;
}
export const DynamicScrollerItem: <T>(
props: DynamicScrollerItemProps<T> & PublicProps,
ctx?: SetupContext<DynamicScrollerItemEmitOptions>,
) => VNode;
export function IdState(options?: { idProp?: (value: any) => unknown }): ComponentOptionsMixin;
}动态高度(DynamicScroller)
vue
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Item {
id: number
text: string
content: string
}
const items = ref<Item[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
content: `Content ${i} `.repeat(Math.floor(Math.random() * 10) + 1)
}))
)
</script>
<template>
<DynamicScroller
:items="items"
:min-item-size="50"
key-field="id"
class="h-400px"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
:size-dependencies="[item.content]"
>
<div class="p-4 border-b border-gray-200">
<h3 class="m-0 mb-2">{{ item.text }}</h3>
<p class="m-0 text-gray-600">{{ item.content }}</p>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>网格布局
点击查看完整代码
vue
<script setup lang="ts">
import { ref } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Item {
id: number
title: string
color: string
}
const items = ref<Item[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: i,
title: `Item ${i}`,
color: `hsl(${(i * 30) % 360}, 70%, 80%)`
}))
)
</script>
<template>
<RecycleScroller
:items="items"
:item-size="150"
key-field="id"
class="h-600px"
:grid-items="4"
>
<template #default="{ item }">
<div
class="h-150px flex items-center justify-center border border-gray-300 font-bold"
:style="{ backgroundColor: item.color }"
>
{{ item.title }}
</div>
</template>
</RecycleScroller>
</template>无限滚动
点击查看完整代码
vue
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Item {
id: number
text: string
}
const items = ref<Item[]>(
Array.from({ length: 50 }, (_, i) => ({
id: i,
text: `Item ${i}`
}))
)
const loading = ref(false)
const loadMore = async () => {
if (loading.value) return
loading.value = true
// 模拟加载
await new Promise(resolve => setTimeout(resolve, 1000))
const currentLength = items.value.length
const newItems = Array.from({ length: 20 }, (_, i) => ({
id: currentLength + i,
text: `Item ${currentLength + i}`
}))
items.value.push(...newItems)
loading.value = false
}
</script>
<template>
<DynamicScroller
:items="items"
:min-item-size="50"
key-field="id"
class="h-400px"
@scroll-end="loadMore"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
>
<div class="h-50px flex items-center px-4 border-b border-gray-200">
{{ item.text }}
</div>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="loading" class="p-4 text-center text-gray-400">
加载中...
</div>
</template>
</DynamicScroller>
</template>对比选择
| 特性 | @vueuse/core | vue-virtual-scroller |
|---|---|---|
| 安装大小 | 小(VueUse 的一部分) | 中等 |
| API 风格 | 组合式函数 | 组件 |
| 动态高度 | ✅ | ✅ |
| 网格布局 | ❌ | ✅ |
| 水平滚动 | ✅ | ✅ |
| 无限滚动 | 需自行实现 | ✅ 内置支持 |
| TypeScript | ✅ 完整支持 | ⚠️ 部分支持 |
| 学习曲线 | 低 | 中等 |
| 配置灵活性 | 中等 | 高 |
| 性能 | 优秀 | 优秀 |
推荐使用场景
使用 @vueuse/core (useVirtualList)
- ✅ 简单的虚拟列表需求
- ✅ 需要更灵活的控制
- ✅ 项目已使用 VueUse
- ✅ 偏好组合式 API
- ✅ 需要轻量级方案
使用 vue-virtual-scroller
- ✅ 复杂的虚拟滚动场景
- ✅ 需要网格布局
- ✅ 需要无限滚动
- ✅ 需要更多内置功能
- ✅ 偏好组件化方案
性能优化建议
1. 合理设置 overscan
typescript
// overscan 值越大,滚动越流畅,但内存占用越高
useVirtualList(items, {
itemHeight: 50,
overscan: 10 // 预渲染 10 个额外项
})2. 使用 key-field
vue
<!-- 确保每项有唯一的 key -->
<RecycleScroller
:items="items"
key-field="id"
/>3. 避免复杂计算
vue
<script setup lang="ts">
// ❌ 不好:在模板中进行复杂计算
// <div>{{ expensiveComputation(item) }}</div>
// ✅ 好:预先计算
const processedItems = computed(() =>
items.value.map(item => ({
...item,
computed: expensiveComputation(item)
}))
)
</script>4. 固定高度优先
vue
<!-- 固定高度性能最佳 -->
<RecycleScroller
:items="items"
:item-size="50"
/>
<!-- 动态高度需要更多计算 -->
<DynamicScroller
:items="items"
:min-item-size="50"
/>