虚拟滚动实现与优化完全指南

HTMLPAGE 团队
22分钟 分钟阅读

深入剖析虚拟滚动的核心原理与实现细节,涵盖固定高度与动态高度场景、Vue 3 组合式 API 实现、性能优化技巧、无障碍支持,以及生产环境的调试与问题排查。

#虚拟滚动 #Virtual Scroll #性能优化 #大列表 #Vue 3

虚拟滚动实现与优化完全指南

当列表包含成千上万条数据时,直接渲染所有 DOM 节点会导致严重的性能问题——页面卡顿、内存溢出、首屏白屏。虚拟滚动(Virtual Scrolling) 是解决这个问题的核心技术,它只渲染当前可见区域内的元素,用户滚动时动态替换内容。

本文将从原理到实现,完整讲解如何构建一个高性能的虚拟滚动组件。


虚拟滚动的核心原理

为什么需要虚拟滚动?

假设一个列表有 10,000 条数据,每条高度 50px:

传统渲染方式:

- DOM 节点数量:10,000 个
- 总高度:500,000px
- 内存占用:可能超过 100MB
- 首次渲染时间:数秒甚至更长

虚拟滚动方式:

- DOM 节点数量:仅可见区域 + 缓冲区,约 20-30 个
- 总高度:通过占位元素模拟
- 内存占用:几 MB
- 首次渲染时间:毫秒级

核心机制

虚拟滚动的本质是 「欺骗」浏览器

┌─────────────────────────────────────────┐
│          占位区域 (上方)                  │ ← 不渲染,仅占位
│          paddingTop = startOffset       │
├─────────────────────────────────────────┤
│                                          │
│          可见区域                         │ ← 实际渲染的元素
│          + 缓冲区                        │
│                                          │
├─────────────────────────────────────────┤
│          占位区域 (下方)                  │ ← 不渲染,仅占位
│          paddingBottom = endOffset      │
└─────────────────────────────────────────┘

关键计算:

  1. 可见区域起始索引startIndex = Math.floor(scrollTop / itemHeight)
  2. 可见区域结束索引endIndex = startIndex + Math.ceil(containerHeight / itemHeight)
  3. 渲染列表visibleItems = items.slice(startIndex, endIndex + bufferSize)
  4. 上方偏移offsetTop = startIndex * itemHeight

固定高度虚拟滚动实现

当每个列表项高度相同时,实现最为简单直接。

Vue 3 基础实现

<!-- components/VirtualListFixed.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

interface Props {
  items: any[]           // 数据列表
  itemHeight: number     // 每项高度(固定)
  containerHeight: number // 容器高度
  bufferSize?: number    // 缓冲区大小
}

const props = withDefaults(defineProps<Props>(), {
  bufferSize: 5
})

const emit = defineEmits<{
  scroll: [scrollTop: number]
  visibleRangeChange: [startIndex: number, endIndex: number]
}>()

const containerRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)

// 计算可见范围
const visibleRange = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight)
  const visibleCount = Math.ceil(props.containerHeight / props.itemHeight)
  const end = start + visibleCount
  
  // 添加缓冲区
  const startWithBuffer = Math.max(0, start - props.bufferSize)
  const endWithBuffer = Math.min(props.items.length, end + props.bufferSize)
  
  return {
    start: startWithBuffer,
    end: endWithBuffer,
    visibleStart: start,
    visibleEnd: end
  }
})

// 可见项目列表
const visibleItems = computed(() => {
  const { start, end } = visibleRange.value
  return props.items.slice(start, end).map((item, index) => ({
    ...item,
    _virtualIndex: start + index  // 保留原始索引
  }))
})

// 总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 上方偏移
const offsetTop = computed(() => visibleRange.value.start * props.itemHeight)

// 滚动处理
const handleScroll = (event: Event) => {
  const target = event.target as HTMLElement
  scrollTop.value = target.scrollTop
  
  emit('scroll', scrollTop.value)
  emit('visibleRangeChange', visibleRange.value.visibleStart, visibleRange.value.visibleEnd)
}

// 滚动到指定索引
const scrollToIndex = (index: number, behavior: ScrollBehavior = 'auto') => {
  if (containerRef.value) {
    containerRef.value.scrollTo({
      top: index * props.itemHeight,
      behavior
    })
  }
}

// 暴露方法
defineExpose({
  scrollToIndex,
  getVisibleRange: () => visibleRange.value
})
</script>

<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    :style="{ height: `${containerHeight}px` }"
    @scroll="handleScroll"
  >
    <!-- 总高度占位 -->
    <div
      class="virtual-list-phantom"
      :style="{ height: `${totalHeight}px` }"
    >
      <!-- 可见内容区域 -->
      <div
        class="virtual-list-content"
        :style="{ transform: `translateY(${offsetTop}px)` }"
      >
        <div
          v-for="item in visibleItems"
          :key="item._virtualIndex"
          class="virtual-list-item"
          :style="{ height: `${itemHeight}px` }"
        >
          <slot :item="item" :index="item._virtualIndex" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
}

.virtual-list-phantom {
  position: relative;
}

.virtual-list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

.virtual-list-item {
  box-sizing: border-box;
}
</style>

使用示例

<!-- pages/users.vue -->
<script setup lang="ts">
const users = ref<User[]>([])

// 加载大量数据
onMounted(async () => {
  users.value = await fetchUsers(10000)
})

const virtualListRef = ref<InstanceType<typeof VirtualListFixed>>()

const scrollToTop = () => {
  virtualListRef.value?.scrollToIndex(0, 'smooth')
}

const scrollToMiddle = () => {
  const middleIndex = Math.floor(users.value.length / 2)
  virtualListRef.value?.scrollToIndex(middleIndex, 'smooth')
}
</script>

<template>
  <div class="page">
    <div class="controls">
      <button @click="scrollToTop">回到顶部</button>
      <button @click="scrollToMiddle">滚动到中间</button>
    </div>
    
    <VirtualListFixed
      ref="virtualListRef"
      :items="users"
      :item-height="60"
      :container-height="600"
      :buffer-size="10"
      @visible-range-change="(start, end) => console.log(`可见范围: ${start} - ${end}`)"
    >
      <template #default="{ item, index }">
        <UserCard :user="item" :rank="index + 1" />
      </template>
    </VirtualListFixed>
  </div>
</template>

动态高度虚拟滚动实现

真实场景中,列表项高度往往不固定——文本长度不同、图片尺寸不同、内容类型不同。这使得计算变得复杂。

核心挑战

  1. 无法预知项目高度:渲染前不知道实际高度
  2. 滚动位置计算复杂:需要累加之前所有项的高度
  3. 性能开销增加:需要在渲染后测量高度

解决方案:预估 + 测量

┌─────────────────────────────────────────┐
│   初始阶段                               │
│   - 使用预估高度计算位置                  │
│   - 渲染可见区域元素                      │
└────────────────────┬────────────────────┘
                     ↓
┌─────────────────────────────────────────┐
│   测量阶段                               │
│   - 获取实际渲染高度                      │
│   - 更新高度缓存                         │
│   - 重新计算位置                         │
└────────────────────┬────────────────────┘
                     ↓
┌─────────────────────────────────────────┐
│   稳定阶段                               │
│   - 使用缓存的实际高度                    │
│   - 位置计算精确                         │
└─────────────────────────────────────────┘

Vue 3 动态高度实现

<!-- components/VirtualListDynamic.vue -->
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'

interface Props {
  items: any[]
  estimatedItemHeight: number  // 预估高度
  containerHeight: number
  bufferSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  bufferSize: 5
})

const containerRef = ref<HTMLElement | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)

// 高度缓存:存储每个项目的实际高度
const heightCache = ref<Map<number, number>>(new Map())

// 位置缓存:存储每个项目的顶部位置
const positionCache = computed(() => {
  const positions: number[] = []
  let currentTop = 0
  
  for (let i = 0; i < props.items.length; i++) {
    positions.push(currentTop)
    currentTop += getItemHeight(i)
  }
  
  return positions
})

// 获取项目高度(优先使用缓存,否则使用预估值)
function getItemHeight(index: number): number {
  return heightCache.value.get(index) ?? props.estimatedItemHeight
}

// 总高度
const totalHeight = computed(() => {
  if (props.items.length === 0) return 0
  
  const lastIndex = props.items.length - 1
  return positionCache.value[lastIndex] + getItemHeight(lastIndex)
})

// 二分查找起始索引
function findStartIndex(scrollTop: number): number {
  let left = 0
  let right = props.items.length - 1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const position = positionCache.value[mid]
    
    if (position === scrollTop) {
      return mid
    } else if (position < scrollTop) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return Math.max(0, left - 1)
}

// 计算可见范围
const visibleRange = computed(() => {
  const start = findStartIndex(scrollTop.value)
  
  // 找结束索引:累加高度直到超过可见区域
  let end = start
  let accumulatedHeight = 0
  
  while (end < props.items.length && accumulatedHeight < props.containerHeight) {
    accumulatedHeight += getItemHeight(end)
    end++
  }
  
  // 添加缓冲区
  const startWithBuffer = Math.max(0, start - props.bufferSize)
  const endWithBuffer = Math.min(props.items.length, end + props.bufferSize)
  
  return {
    start: startWithBuffer,
    end: endWithBuffer
  }
})

// 可见项目
const visibleItems = computed(() => {
  const { start, end } = visibleRange.value
  return props.items.slice(start, end).map((item, index) => ({
    ...item,
    _virtualIndex: start + index
  }))
})

// 上方偏移
const offsetTop = computed(() => {
  const { start } = visibleRange.value
  return start > 0 ? positionCache.value[start] : 0
})

// 滚动处理
const handleScroll = (event: Event) => {
  const target = event.target as HTMLElement
  scrollTop.value = target.scrollTop
}

// 测量实际高度
async function measureHeights() {
  await nextTick()
  
  if (!contentRef.value) return
  
  const items = contentRef.value.children
  let hasChanges = false
  
  for (let i = 0; i < items.length; i++) {
    const element = items[i] as HTMLElement
    const index = parseInt(element.dataset.index || '0', 10)
    const height = element.getBoundingClientRect().height
    
    const cachedHeight = heightCache.value.get(index)
    if (cachedHeight !== height) {
      heightCache.value.set(index, height)
      hasChanges = true
    }
  }
  
  // 如果高度有变化,可能需要调整滚动位置
  if (hasChanges) {
    // 触发重新计算
    heightCache.value = new Map(heightCache.value)
  }
}

// 监听可见项变化,测量高度
watch(visibleItems, () => {
  measureHeights()
}, { flush: 'post' })

// ResizeObserver 监听元素尺寸变化
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  resizeObserver = new ResizeObserver(() => {
    measureHeights()
  })
  
  if (contentRef.value) {
    resizeObserver.observe(contentRef.value)
  }
})

onUnmounted(() => {
  resizeObserver?.disconnect()
})

// 滚动到指定索引
function scrollToIndex(index: number, behavior: ScrollBehavior = 'auto') {
  if (containerRef.value && positionCache.value[index] !== undefined) {
    containerRef.value.scrollTo({
      top: positionCache.value[index],
      behavior
    })
  }
}

defineExpose({
  scrollToIndex,
  measureHeights
})
</script>

<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    :style="{ height: `${containerHeight}px` }"
    @scroll="handleScroll"
  >
    <div
      class="virtual-list-phantom"
      :style="{ height: `${totalHeight}px` }"
    >
      <div
        ref="contentRef"
        class="virtual-list-content"
        :style="{ transform: `translateY(${offsetTop}px)` }"
      >
        <div
          v-for="item in visibleItems"
          :key="item._virtualIndex"
          :data-index="item._virtualIndex"
          class="virtual-list-item"
        >
          <slot :item="item" :index="item._virtualIndex" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
}

.virtual-list-phantom {
  position: relative;
}

.virtual-list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}
</style>

性能优化技巧

1. 滚动节流与防抖

频繁的滚动事件会导致大量计算。使用 requestAnimationFrame 进行优化:

// composables/useThrottledScroll.ts
export function useThrottledScroll(callback: (scrollTop: number) => void) {
  let rafId: number | null = null
  let lastScrollTop = 0
  
  const handleScroll = (event: Event) => {
    const target = event.target as HTMLElement
    lastScrollTop = target.scrollTop
    
    if (rafId === null) {
      rafId = requestAnimationFrame(() => {
        callback(lastScrollTop)
        rafId = null
      })
    }
  }
  
  const cleanup = () => {
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
    }
  }
  
  return { handleScroll, cleanup }
}

在组件中使用:

const { handleScroll, cleanup } = useThrottledScroll((scrollTop) => {
  // 更新滚动位置
  scrollTopRef.value = scrollTop
})

onUnmounted(() => {
  cleanup()
})

2. 使用 CSS contain 属性

告诉浏览器容器内容不会影响外部布局:

.virtual-list-container {
  contain: strict;  /* 最强隔离 */
}

.virtual-list-item {
  contain: layout style;
}

contain 值说明:

作用
layout元素布局不影响外部
style样式计算隔离
paint元素不会绘制到边界外
size元素尺寸不依赖子元素
strict等于 size layout paint style
content等于 layout paint style

3. 使用 will-change 提示

.virtual-list-content {
  will-change: transform;
}

注意will-change 会创建新的图层,过度使用反而影响性能。只在确实需要时使用。

4. 避免复杂选择器

/* 避免 */
.virtual-list-container .virtual-list-item:nth-child(odd) .item-content > span {
  color: #333;
}

/* 推荐 */
.item-text {
  color: #333;
}

5. 列表项组件优化

<!-- components/ListItem.vue -->
<script setup lang="ts">
import { shallowRef } from 'vue'

// 使用 shallowRef 避免深层响应式
const props = defineProps<{
  data: any
}>()

// 避免不必要的响应式
const staticData = shallowRef(props.data)
</script>

<template>
  <div class="list-item">
    <!-- 使用 v-once 标记不变的内容 -->
    <span v-once class="item-id">{{ data.id }}</span>
    
    <!-- 动态内容 -->
    <span class="item-name">{{ data.name }}</span>
  </div>
</template>

6. 使用 key 的最佳实践

<!-- 使用稳定的唯一标识 -->
<div v-for="item in visibleItems" :key="item.id">
  ...
</div>

<!-- 避免使用索引作为 key(在虚拟滚动中会导致问题) -->
<div v-for="(item, index) in visibleItems" :key="index">
  <!-- 滚动时会错误复用元素 -->
</div>

高级功能实现

无限滚动加载

<!-- components/VirtualInfiniteList.vue -->
<script setup lang="ts">
interface Props {
  items: any[]
  itemHeight: number
  containerHeight: number
  loadMoreThreshold?: number  // 触发加载的阈值(距底部的项数)
  loading?: boolean
  hasMore?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  loadMoreThreshold: 5,
  loading: false,
  hasMore: true
})

const emit = defineEmits<{
  loadMore: []
}>()

const containerRef = ref<HTMLElement | null>(null)

// 检查是否需要加载更多
const checkLoadMore = () => {
  if (props.loading || !props.hasMore) return
  
  const { end } = visibleRange.value
  const remainingItems = props.items.length - end
  
  if (remainingItems <= props.loadMoreThreshold) {
    emit('loadMore')
  }
}

// 滚动时检查
watch(scrollTop, checkLoadMore)

// 初始检查(数据较少时可能需要立即加载)
onMounted(checkLoadMore)
</script>

<template>
  <div ref="containerRef" class="virtual-list-container" @scroll="handleScroll">
    <!-- 列表内容 -->
    <div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
      <div class="virtual-list-content" :style="{ transform: `translateY(${offsetTop}px)` }">
        <div v-for="item in visibleItems" :key="item._virtualIndex">
          <slot :item="item" :index="item._virtualIndex" />
        </div>
      </div>
    </div>
    
    <!-- 加载指示器 -->
    <div v-if="loading" class="loading-indicator">
      <slot name="loading">
        <span>加载中...</span>
      </slot>
    </div>
    
    <!-- 无更多数据 -->
    <div v-if="!hasMore && !loading" class="no-more">
      <slot name="no-more">
        <span>没有更多了</span>
      </slot>
    </div>
  </div>
</template>

使用示例:

<script setup>
const items = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)

const loadMore = async () => {
  if (loading.value) return
  
  loading.value = true
  try {
    const newItems = await fetchItems(page.value)
    items.value.push(...newItems)
    page.value++
    
    if (newItems.length < 20) {
      hasMore.value = false
    }
  } finally {
    loading.value = false
  }
}

onMounted(loadMore)
</script>

<template>
  <VirtualInfiniteList
    :items="items"
    :item-height="60"
    :container-height="600"
    :loading="loading"
    :has-more="hasMore"
    @load-more="loadMore"
  >
    <template #default="{ item }">
      <ItemCard :data="item" />
    </template>
    <template #loading>
      <LoadingSpinner />
    </template>
  </VirtualInfiniteList>
</template>

横向虚拟滚动

<!-- components/VirtualListHorizontal.vue -->
<script setup lang="ts">
// 与垂直滚动类似,但改为水平方向
const scrollLeft = ref(0)

const visibleRange = computed(() => {
  const start = Math.floor(scrollLeft.value / props.itemWidth)
  const visibleCount = Math.ceil(props.containerWidth / props.itemWidth)
  const end = start + visibleCount
  
  return {
    start: Math.max(0, start - props.bufferSize),
    end: Math.min(props.items.length, end + props.bufferSize)
  }
})

const offsetLeft = computed(() => visibleRange.value.start * props.itemWidth)
const totalWidth = computed(() => props.items.length * props.itemWidth)
</script>

<template>
  <div
    class="virtual-list-horizontal"
    :style="{ width: `${containerWidth}px` }"
    @scroll="handleScroll"
  >
    <div
      class="virtual-list-phantom"
      :style="{ width: `${totalWidth}px` }"
    >
      <div
        class="virtual-list-content"
        :style="{ transform: `translateX(${offsetLeft}px)` }"
      >
        <div
          v-for="item in visibleItems"
          :key="item._virtualIndex"
          class="virtual-list-item"
          :style="{ width: `${itemWidth}px` }"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-horizontal {
  overflow-x: auto;
  overflow-y: hidden;
  white-space: nowrap;
}

.virtual-list-content {
  display: flex;
}

.virtual-list-item {
  flex-shrink: 0;
}
</style>

网格虚拟滚动

// composables/useVirtualGrid.ts
interface VirtualGridOptions {
  items: Ref<any[]>
  containerWidth: Ref<number>
  containerHeight: Ref<number>
  itemWidth: number
  itemHeight: number
  gap?: number
}

export function useVirtualGrid(options: VirtualGridOptions) {
  const { items, containerWidth, containerHeight, itemWidth, itemHeight, gap = 0 } = options
  
  const scrollTop = ref(0)
  
  // 每行项目数
  const columnsPerRow = computed(() => {
    return Math.floor((containerWidth.value + gap) / (itemWidth + gap))
  })
  
  // 总行数
  const totalRows = computed(() => {
    return Math.ceil(items.value.length / columnsPerRow.value)
  })
  
  // 可见行范围
  const visibleRowRange = computed(() => {
    const rowHeight = itemHeight + gap
    const startRow = Math.floor(scrollTop.value / rowHeight)
    const visibleRows = Math.ceil(containerHeight.value / rowHeight)
    const endRow = startRow + visibleRows
    
    return {
      start: Math.max(0, startRow - 1),
      end: Math.min(totalRows.value, endRow + 1)
    }
  })
  
  // 可见项目
  const visibleItems = computed(() => {
    const { start: startRow, end: endRow } = visibleRowRange.value
    const startIndex = startRow * columnsPerRow.value
    const endIndex = endRow * columnsPerRow.value
    
    return items.value.slice(startIndex, endIndex).map((item, i) => ({
      ...item,
      _virtualIndex: startIndex + i,
      _row: Math.floor((startIndex + i) / columnsPerRow.value),
      _col: (startIndex + i) % columnsPerRow.value
    }))
  })
  
  // 总高度
  const totalHeight = computed(() => {
    return totalRows.value * (itemHeight + gap) - gap
  })
  
  // 偏移量
  const offsetTop = computed(() => {
    return visibleRowRange.value.start * (itemHeight + gap)
  })
  
  return {
    scrollTop,
    columnsPerRow,
    visibleItems,
    totalHeight,
    offsetTop
  }
}

无障碍(A11y)支持

虚拟滚动对屏幕阅读器用户来说是个挑战,因为大部分内容实际上不存在于 DOM 中。

基本 ARIA 属性

<template>
  <div
    role="list"
    aria-label="用户列表"
    :aria-rowcount="items.length"
  >
    <div
      v-for="item in visibleItems"
      :key="item._virtualIndex"
      role="listitem"
      :aria-posinset="item._virtualIndex + 1"
      :aria-setsize="items.length"
    >
      <slot :item="item" />
    </div>
  </div>
</template>

键盘导航支持

// composables/useVirtualListA11y.ts
export function useVirtualListA11y(options: {
  items: Ref<any[]>
  scrollToIndex: (index: number) => void
  getVisibleRange: () => { start: number; end: number }
}) {
  const { items, scrollToIndex, getVisibleRange } = options
  const focusedIndex = ref(-1)
  
  const handleKeyDown = (event: KeyboardEvent) => {
    const { start, end } = getVisibleRange()
    
    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault()
        focusedIndex.value = Math.min(focusedIndex.value + 1, items.value.length - 1)
        ensureVisible(focusedIndex.value)
        break
        
      case 'ArrowUp':
        event.preventDefault()
        focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
        ensureVisible(focusedIndex.value)
        break
        
      case 'Home':
        event.preventDefault()
        focusedIndex.value = 0
        scrollToIndex(0)
        break
        
      case 'End':
        event.preventDefault()
        focusedIndex.value = items.value.length - 1
        scrollToIndex(focusedIndex.value)
        break
        
      case 'PageDown':
        event.preventDefault()
        const pageSize = end - start
        focusedIndex.value = Math.min(focusedIndex.value + pageSize, items.value.length - 1)
        scrollToIndex(focusedIndex.value)
        break
        
      case 'PageUp':
        event.preventDefault()
        const pageSizeUp = end - start
        focusedIndex.value = Math.max(focusedIndex.value - pageSizeUp, 0)
        scrollToIndex(focusedIndex.value)
        break
    }
  }
  
  const ensureVisible = (index: number) => {
    const { start, end } = getVisibleRange()
    
    if (index < start || index >= end) {
      scrollToIndex(index)
    }
  }
  
  return {
    focusedIndex,
    handleKeyDown
  }
}

调试与问题排查

常见问题

1. 滚动时内容闪烁

原因:缓冲区太小或没有缓冲区

解决:增加 bufferSize

// 原来
<VirtualList :buffer-size="2" ... />

// 调整
<VirtualList :buffer-size="10" ... />

2. 滚动到底部时有空白

原因:高度计算不准确

解决:确保总高度计算正确

const totalHeight = computed(() => {
  // 确保包含所有项目的高度
  let height = 0
  for (let i = 0; i < items.value.length; i++) {
    height += getItemHeight(i)
  }
  return height
})

3. 滚动位置跳动

原因:动态高度场景下,高度预估与实际差距大

解决

  1. 使用更准确的预估高度
  2. 在高度变化时调整滚动位置
watch(heightCache, (newCache, oldCache) => {
  // 计算高度差
  const heightDiff = calculateHeightDiff(newCache, oldCache)
  
  // 调整滚动位置
  if (heightDiff !== 0 && containerRef.value) {
    containerRef.value.scrollTop += heightDiff
  }
})

4. 内存持续增长

原因:高度缓存没有清理

解决:在数据变化时清理缓存

watch(() => props.items, (newItems, oldItems) => {
  if (newItems.length < oldItems.length) {
    // 数据减少,清理多余缓存
    for (let i = newItems.length; i < oldItems.length; i++) {
      heightCache.value.delete(i)
    }
  }
}, { deep: false })

性能分析工具

// utils/virtual-list-debug.ts
export function createVirtualListDebugger() {
  let renderCount = 0
  let lastRenderTime = 0
  
  return {
    onRender(visibleCount: number) {
      renderCount++
      const now = performance.now()
      const fps = lastRenderTime ? 1000 / (now - lastRenderTime) : 0
      lastRenderTime = now
      
      console.log(`[VirtualList] Render #${renderCount}`, {
        visibleItems: visibleCount,
        fps: fps.toFixed(1)
      })
    },
    
    logMemory() {
      if (performance.memory) {
        console.log('[VirtualList] Memory:', {
          usedJSHeapSize: `${(performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2)} MB`,
          totalJSHeapSize: `${(performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2)} MB`
        })
      }
    }
  }
}

第三方库推荐

如果不想从头实现,以下库提供了成熟的虚拟滚动方案:

库名框架特点
vue-virtual-scrollerVue 3官方推荐,功能完整
@tanstack/vue-virtualVue 3TanStack 出品,轻量高效
react-windowReact轻量,Dan Abramov 推荐
react-virtualizedReact功能最全,但体积较大
@angular/cdk/scrollingAngularAngular CDK 官方实现

@tanstack/vue-virtual 使用示例

<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'

const items = ref(Array.from({ length: 10000 }, (_, i) => `Item ${i}`))
const parentRef = ref<HTMLElement | null>(null)

const virtualizer = useVirtualizer({
  count: items.value.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 50,
  overscan: 5
})
</script>

<template>
  <div ref="parentRef" style="height: 500px; overflow: auto;">
    <div
      :style="{
        height: `${virtualizer.getTotalSize()}px`,
        position: 'relative'
      }"
    >
      <div
        v-for="virtualRow in virtualizer.getVirtualItems()"
        :key="virtualRow.key"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: `${virtualRow.size}px`,
          transform: `translateY(${virtualRow.start}px)`
        }"
      >
        {{ items[virtualRow.index] }}
      </div>
    </div>
  </div>
</template>

总结

虚拟滚动是处理大列表的核心技术,关键要点:

  1. 核心原理:只渲染可见区域 + 缓冲区的元素
  2. 固定高度:计算简单,性能最优
  3. 动态高度:需要预估 + 测量 + 缓存
  4. 性能优化:滚动节流、CSS contain、避免复杂选择器
  5. 无障碍支持:ARIA 属性 + 键盘导航
  6. 调试技巧:关注缓冲区、高度计算、内存泄漏

根据场景复杂度选择实现方式:

  • 简单固定高度列表:手写实现即可
  • 复杂动态场景:使用成熟的第三方库
  • 特殊需求:基于本文实现进行定制