Skip to content

虚拟滚动

处理大量数据列表的虚拟滚动解决方案。

@vueuse/core (useVirtualList)

基本信息

特点

  • ✅ 轻量级
  • ✅ 响应式
  • ✅ TypeScript 支持
  • ✅ 组合式 API
  • ✅ 灵活配置

安装

bash
npm install @vueuse/core
bash
pnpm add @vueuse/core
bash
yarn add @vueuse/core
bash
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/components
bash
pnpm add @vueuse/components
bash
yarn add @vueuse/components
bash
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>

虚拟列表 + 无限滚动

结合 useVirtualListuseInfiniteScroll 实现高性能的无限加载列表。

点击查看完整代码
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>

关键要点:

  1. 数据管理:使用 ref 存储所有数据,useVirtualList 自动处理可见项
  2. 无限滚动:通过 useInfiniteScroll 监听容器滚动,触发加载
  3. 性能优化
    • 设置合理的 distance 值(距离底部多远触发)
    • 使用 interval 节流避免频繁触发
    • 虚拟滚动只渲染可见项,保持高性能
  4. 用户体验
    • 显示加载状态
    • 提供回到顶部功能
    • 显示数据总数和加载完成提示

vue-virtual-scroller

基本信息

特点

  • ✅ 高性能
  • ✅ 动态高度
  • ✅ 多种模式
  • ✅ 网格布局
  • ✅ 丰富的配置选项

安装

bash
npm install vue-virtual-scroller
bash
pnpm add vue-virtual-scroller
bash
yarn add vue-virtual-scroller
bash
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/corevue-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"
/>