虚拟滚动实现原理:让万级数据列表丝滑渲染!

109 阅读4分钟

本文手把手教你用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支持
可扩展:支持动态高度、滚动控制等进阶功能

直接复制上面的代码,亲自尝试一下你就能拥有一个生产级的虚拟滚动组件!

进一步学习

如果你想深入了解虚拟滚动的更多细节和优化技巧,可以研究:

  • 动态高度计算优化
  • 滚动节流和防抖
  • 浏览器兼容性处理
  • 移动端适配

希望这篇文章对你有所帮助!如果有任何问题,欢迎在评论区讨论~