虚拟滚动实现与优化完全指南
当列表包含成千上万条数据时,直接渲染所有 DOM 节点会导致严重的性能问题——页面卡顿、内存溢出、首屏白屏。虚拟滚动(Virtual Scrolling) 是解决这个问题的核心技术,它只渲染当前可见区域内的元素,用户滚动时动态替换内容。
本文将从原理到实现,完整讲解如何构建一个高性能的虚拟滚动组件。
虚拟滚动的核心原理
为什么需要虚拟滚动?
假设一个列表有 10,000 条数据,每条高度 50px:
传统渲染方式:
- DOM 节点数量:10,000 个
- 总高度:500,000px
- 内存占用:可能超过 100MB
- 首次渲染时间:数秒甚至更长
虚拟滚动方式:
- DOM 节点数量:仅可见区域 + 缓冲区,约 20-30 个
- 总高度:通过占位元素模拟
- 内存占用:几 MB
- 首次渲染时间:毫秒级
核心机制
虚拟滚动的本质是 「欺骗」浏览器:
┌─────────────────────────────────────────┐
│ 占位区域 (上方) │ ← 不渲染,仅占位
│ paddingTop = startOffset │
├─────────────────────────────────────────┤
│ │
│ 可见区域 │ ← 实际渲染的元素
│ + 缓冲区 │
│ │
├─────────────────────────────────────────┤
│ 占位区域 (下方) │ ← 不渲染,仅占位
│ paddingBottom = endOffset │
└─────────────────────────────────────────┘
关键计算:
- 可见区域起始索引:
startIndex = Math.floor(scrollTop / itemHeight) - 可见区域结束索引:
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) - 渲染列表:
visibleItems = items.slice(startIndex, endIndex + bufferSize) - 上方偏移:
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>
动态高度虚拟滚动实现
真实场景中,列表项高度往往不固定——文本长度不同、图片尺寸不同、内容类型不同。这使得计算变得复杂。
核心挑战
- 无法预知项目高度:渲染前不知道实际高度
- 滚动位置计算复杂:需要累加之前所有项的高度
- 性能开销增加:需要在渲染后测量高度
解决方案:预估 + 测量
┌─────────────────────────────────────────┐
│ 初始阶段 │
│ - 使用预估高度计算位置 │
│ - 渲染可见区域元素 │
└────────────────────┬────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 测量阶段 │
│ - 获取实际渲染高度 │
│ - 更新高度缓存 │
│ - 重新计算位置 │
└────────────────────┬────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 稳定阶段 │
│ - 使用缓存的实际高度 │
│ - 位置计算精确 │
└─────────────────────────────────────────┘
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. 滚动位置跳动
原因:动态高度场景下,高度预估与实际差距大
解决:
- 使用更准确的预估高度
- 在高度变化时调整滚动位置
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-scroller | Vue 3 | 官方推荐,功能完整 |
| @tanstack/vue-virtual | Vue 3 | TanStack 出品,轻量高效 |
| react-window | React | 轻量,Dan Abramov 推荐 |
| react-virtualized | React | 功能最全,但体积较大 |
| @angular/cdk/scrolling | Angular | Angular 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>
总结
虚拟滚动是处理大列表的核心技术,关键要点:
- 核心原理:只渲染可见区域 + 缓冲区的元素
- 固定高度:计算简单,性能最优
- 动态高度:需要预估 + 测量 + 缓存
- 性能优化:滚动节流、CSS contain、避免复杂选择器
- 无障碍支持:ARIA 属性 + 键盘导航
- 调试技巧:关注缓冲区、高度计算、内存泄漏
根据场景复杂度选择实现方式:
- 简单固定高度列表:手写实现即可
- 复杂动态场景:使用成熟的第三方库
- 特殊需求:基于本文实现进行定制


