听说你还不会虚拟列表?
虚拟列表是长列表的常见性能优化。原文介绍了
vue-virtual-scroller和固定高度的手写版本,思路仍然有效;但组件库版本、动态高度、缓冲区和“渲染一万条数据”的表述需要补充边界。
一、什么是虚拟列表
虚拟列表只把可视区域附近的少量列表项渲染成 DOM,未进入窗口的项目不创建或暂时回收 DOM,同时使用一个占位区域维持完整滚动高度。
viewport(固定高度、可滚动)
├── spacer(模拟全部数据的总高度)
└── visible items(当前窗口 + overscan 缓冲区)
它减少的是同时存在的 DOM 节点和布局工作,不是数据本身:如果前端仍然一次性请求并保存一百万条数据,虚拟列表不会自动降低网络、解析和 JavaScript 内存成本。数据分页、服务端查询和虚拟列表可以组合使用。
二、什么时候需要虚拟列表
适合:
- 表格、消息、日志、文件等项目很多的列表;
- 用户需要连续滚动,不适合分页;
- 单个项目 DOM 结构较复杂,普通列表产生大量布局和更新成本。
使用前应先测量。几百个简单文本节点未必值得引入虚拟列表;虚拟列表会增加滚动计算、测量、焦点管理和键盘导航复杂度。
三、使用 vue-virtual-scroller
vue-virtual-scroller 存在 Vue 2 和 Vue 3 不同版本/分支,安装时要按照项目 Vue 版本选择,不要直接复制旧文章中的依赖版本。当前 Vue 3 线的文档使用 RecycleScroller 和 DynamicScroller,示例以 Vue 3 为准:
pnpm add vue-virtual-scroller
在入口或组件中引入样式:
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
1. RecycleScroller:固定高度
当每个项目高度相同,使用 RecycleScroller:
<!-- components/VirtualRecycleScroller.vue -->
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
defineProps<{
items: { id: string | number, label: string }[]
}>()
</script>
<template>
<RecycleScroller
class="scroller"
:items="items"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="desc">{{ item.label }}</div>
</RecycleScroller>
</template>
<style scoped>
.scroller {
height: 300px;
}
.desc {
box-sizing: border-box;
height: 50px;
border-bottom: 1px solid #ddd;
line-height: 50px;
}
</style>
item-size 是布局计算使用的项目高度。项目内容不能随意超出该高度,否则会发生重叠或裁切。key-field 应指向稳定且唯一的业务 ID,不要使用会变化的数组 index。

2. DynamicScroller:动态高度
项目高度由文本、图片或展开状态决定时,使用 DynamicScroller 和 DynamicScrollerItem:
<!-- components/VirtualDynamicScroller.vue -->
<script setup lang="ts">
import {
DynamicScroller,
DynamicScrollerItem,
} from 'vue-virtual-scroller'
defineProps<{
items: { id: string | number, label: string }[]
}>()
</script>
<template>
<DynamicScroller
class="scroller"
:items="items"
:min-item-size="50"
key-field="id"
v-slot="{ item, index, active }"
>
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.label]"
:data-index="index"
>
<div class="desc">{{ item.label }}</div>
</DynamicScrollerItem>
</DynamicScroller>
</template>
<style scoped>
.scroller {
height: 300px;
}
.desc {
padding: 12px;
border-bottom: 1px solid #ddd;
line-height: 1.5;
}
</style>
min-item-size 是初次估算使用的最小高度,不是所有项目的最终固定高度;size-dependencies 应包含会改变项目尺寸的数据。图片加载、字体变化和展开折叠仍可能触发重新测量。

四、虚拟列表的计算过程
以固定项目高度 itemSize 为例:
const startIndex = Math.floor(scrollTop / itemSize)
const visibleCount = Math.ceil(viewportHeight / itemSize)
const overscan = 3
const start = Math.max(0, startIndex - overscan)
const end = Math.min(items.length, startIndex + visibleCount + overscan)
const offset = start * itemSize
const totalHeight = items.length * itemSize
滚动时主要更新:
scrollTop;- 可视窗口开始和结束索引;
items.slice(start, end);- 可视列表的
transform偏移; - 占位区域的总高度。


原文的三层结构可以概括为:
- 可视区容器:固定高度并监听滚动;
- 可滚动区域/占位层:高度等于全部项目高度,用来产生正确的滚动条;
- 可视区列表:只渲染当前窗口项目,通过
translateY放到正确位置。
动态高度时不能简单使用 index * itemSize。常见做法是保存已测量高度和前缀和,用二分查找根据 scrollTop 找到项目,再随着测量结果修正估算位置;成熟库已经处理了很多这些边界。
五、手写一个固定高度的 Vue 3 虚拟列表
下面的组件保留原文手写版的核心结构,并补充了 overscan、ResizeObserver、被动滚动监听语义和卸载清理。
<!-- components/MyVirtualScroller.vue -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
type Item = {
id: string | number
label: string
}
const props = withDefaults(defineProps<{
items: Item[]
itemSize?: number
overscan?: number
}>(), {
itemSize: 50,
overscan: 3,
})
const viewport = ref<HTMLElement | null>(null)
const viewportHeight = ref(0)
const scrollTop = ref(0)
const visibleCount = computed(() => {
return Math.ceil(viewportHeight.value / props.itemSize)
})
const firstVisible = computed(() => {
return Math.floor(scrollTop.value / props.itemSize)
})
const start = computed(() => {
return Math.max(0, firstVisible.value - props.overscan)
})
const end = computed(() => {
return Math.min(
props.items.length,
firstVisible.value + visibleCount.value + props.overscan,
)
})
const visibleData = computed(() => props.items.slice(start.value, end.value))
const offset = computed(() => start.value * props.itemSize)
const totalHeight = computed(() => props.items.length * props.itemSize)
function onScroll(event: Event) {
scrollTop.value = (event.target as HTMLElement).scrollTop
}
let resizeObserver: ResizeObserver | undefined
onMounted(() => {
if (!viewport.value) return
const updateHeight = () => {
viewportHeight.value = viewport.value?.clientHeight || 0
}
updateHeight()
if ('ResizeObserver' in window) {
resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(viewport.value)
}
}
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div
ref="viewport"
class="virtual-viewport"
@scroll.passive="onScroll"
>
<div
class="virtual-spacer"
:style="{ height: `${totalHeight}px` }"
/>
<div
class="virtual-items"
:style="{ transform: `translateY(${offset}px)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
class="virtual-item"
:style="{ height: `${props.itemSize}px` }"
>
{{ item.label }}
</div>
</div>
</div>
</template>
<style scoped>
.virtual-viewport {
position: relative;
height: 300px;
overflow: auto;
}
.virtual-spacer {
pointer-events: none;
}
.virtual-items {
position: absolute;
inset: 0 0 auto;
will-change: transform;
}
.virtual-item {
box-sizing: border-box;
border-bottom: 1px solid #ddd;
line-height: 1.5;
}
</style>


使用:
<script setup lang="ts">
import MyVirtualScroller from './components/MyVirtualScroller.vue'
const list = Array.from({ length: 10_000 }, (_, id) => ({
id,
label: `virtual-list ${id}`,
}))
</script>
<template>
<MyVirtualScroller :items="list" />
</template>
手写版本需要继续完善的地方
- 动态高度和图片加载后的重新测量;
- 首尾 overscan 和快速滚动时的空白;
- 键盘焦点、屏幕阅读器和
aria-rowindex; - 项目插入/删除后保持滚动锚点;
- 横向列表、网格和 RTL;
ResizeObserver、IntersectionObserver 不可用时的降级;- 频繁 scroll 回调的调度与性能测量。
六、常见误区
1. 虚拟列表会让一万条数据“瞬间加载”
它只减少同时渲染的 DOM。数据请求、JSON 解析、响应式代理和内存仍然可能发生,必要时要使用分页、游标查询或服务端过滤。
2. 动态高度可以直接套固定高度算法
不可以。固定高度通过除法计算索引;动态高度需要测量、估算和位置修正。
3. 只渲染可见行,不需要稳定 key
仍然需要稳定 key。回收的 DOM 会被复用于不同数据项,错误的 key 可能造成输入框、局部状态和动画状态错配。
4. 虚拟列表一定比普通 v-for 快
当列表很短、项目很简单或滚动交互要求不高时,虚拟列表的计算和组件开销可能不划算。应使用 Performance 面板和真实设备测量。
七、总结
虚拟列表的核心是“完整滚动高度 + 可视窗口 + 偏移渲染”:
- 计算当前可视区的起止索引;
- 只渲染窗口和少量缓冲项目;
- 使用占位层维持滚动条;
- 使用 transform 把窗口移动到正确位置;
- 固定高度可用简单除法,动态高度需要测量和估算;
- 它减少 DOM,不自动减少数据加载;
- 生产项目优先选择与 Vue 版本匹配、经过测试的虚拟滚动库。
参考资料
原文作者:前端阿飞。原文关于 RecycleScroller、DynamicScroller、三层结构和固定高度手写实现的主线予以保留,依赖版本、动态高度、数据加载边界、远程图片和推广内容已整理。