结合“康熙选秀”,讲讲虚拟列表
原文用“康熙一次不看一万名秀女,而是分批查看”的故事解释虚拟列表。这个比喻仍然很好:数据可以有一万条,但浏览器只需要同时创建视口附近的一小部分 DOM。
本文保留原文的故事、固定高度算法和 Vue 示例,并修正原代码中的高度变量拼写、窗口监听清理、索引计算和“虚拟列表等于分页/懒加载”等混淆。
一、场景:一次渲染一万条数据会怎样
康熙命人找来一万名女子,但不可能一次让所有人进入大殿。张廷玉把人分成几批:当前正在查看的一批进入大殿,已经看过的在左偏殿,尚未轮到的在右偏殿。大殿始终只容纳少量人,但队列和位置仍然保持完整。
浏览器渲染列表也类似:
- 完整数据:一万条业务数据;
- 可视区域:当前滚动容器能看到的部分;
- overscan:可视区域上下额外预渲染的几条,防止快速滚动时白屏;
- 占位空间:用高度或 transform 模拟完整列表的滚动长度;
- 实际 DOM:只创建当前区域附近的几十个列表项。


二、虚拟列表和其他方案的区别
虚拟列表、分页、无限滚动和图片懒加载经常一起出现,但不是同一件事:
| 方案 | 主要解决的问题 |
|---|---|
| 分页 | 控制一次从服务端取多少数据 |
| 无限滚动 | 用户接近底部时继续请求下一页 |
| 图片懒加载 | 图片进入视口附近才下载 |
| 虚拟列表 | 数据已在客户端时,控制同时存在的 DOM 数量 |
| 服务端筛选 | 避免把不需要的数据传到浏览器 |
可以组合使用:分页负责数据量,虚拟列表负责 DOM 量,IntersectionObserver 负责触底请求,图片 loading="lazy" 负责媒体资源。
虚拟列表并不会自动减少数据请求,也不会自动减少内存中的数组。若 100 万条数据全部放在客户端,仍然要考虑网络、解析、内存和搜索成本。
三、固定高度虚拟列表的核心公式
设:
H:滚动容器可视高度;h:每一项固定高度;T:当前scrollTop;n:数据总条数;overscan:上下额外渲染数量。
可以计算:
第一条可见数据 ≈ floor(T / h)
可视条数 ≈ ceil(H / h)
渲染起点 = max(0, 第一条可见数据 - overscan)
渲染终点 = min(n, 第一条可见数据 + 可视条数 + overscan)
上方偏移 = 渲染起点 * h
总滚动高度 = n * h
只要列表项高度固定,滚动位置与索引之间就可以 O(1) 互相转换。若列表项高度不同,scrollTop / h 不再可靠,需要测量和维护高度前缀和,复杂度和实现方式都会变化。

四、Vue 3 固定高度实现
下面是一个可运行思路较完整的 Vue 3.5 示例。它使用 transform 模拟上方占位空间,用一个固定高度的外层元素保持滚动条总长度。
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
interface Row {
id: number
message: string
}
const rows = ref<Row[]>(
Array.from({ length: 100_000 }, (_, id) => ({
id,
message: `第 ${id + 1} 条数据`,
})),
)
const viewport = ref<HTMLElement | null>(null)
// 与 CSS 的 400px 初始高度保持一致,保证 SSR 首屏也有列表内容
const viewportHeight = ref(400)
const scrollTop = ref(0)
const itemHeight = 48
const overscan = 6
const firstVisibleIndex = computed(() => {
return Math.floor(scrollTop.value / itemHeight)
})
const visibleCount = computed(() => {
return Math.ceil(viewportHeight.value / itemHeight)
})
const startIndex = computed(() => {
return Math.max(0, firstVisibleIndex.value - overscan)
})
const endIndex = computed(() => {
return Math.min(
rows.value.length,
firstVisibleIndex.value + visibleCount.value + overscan,
)
})
const visibleRows = computed(() => {
return rows.value.slice(startIndex.value, endIndex.value)
})
const offsetY = computed(() => startIndex.value * itemHeight)
const totalHeight = computed(() => rows.value.length * itemHeight)
let resizeObserver: ResizeObserver | undefined
let frame = 0
let pendingScrollTop = 0
function updateScrollTop() {
scrollTop.value = pendingScrollTop
frame = 0
}
function handleScroll(event: Event) {
pendingScrollTop = (event.currentTarget as HTMLElement).scrollTop
// passive 不等于节流;用 requestAnimationFrame 合并同一帧内的滚动更新
if (!frame) {
frame = requestAnimationFrame(updateScrollTop)
}
}
onMounted(() => {
if (!viewport.value) return
const updateHeight = () => {
viewportHeight.value = viewport.value?.clientHeight ?? 0
}
resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(viewport.value)
updateHeight()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
if (frame) {
cancelAnimationFrame(frame)
}
})
</script>
<template>
<div
ref="viewport"
class="virtual-viewport"
role="list"
tabindex="0"
@scroll.passive="handleScroll"
>
<div
class="virtual-content"
:style="{ height: `${totalHeight}px` }"
>
<div
class="virtual-items"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="(row, offset) in visibleRows"
:key="row.id"
class="virtual-row"
role="listitem"
:aria-setsize="rows.length"
:aria-posinset="startIndex + offset + 1"
>
{{ row.message }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-viewport {
height: 400px;
overflow: auto;
contain: strict;
}
.virtual-content {
position: relative;
}
.virtual-items {
position: absolute;
inset: 0 0 auto;
will-change: transform;
}
.virtual-row {
box-sizing: border-box;
height: 48px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #eee;
}
</style>
4.1 为什么需要 key
<div v-for="row in visibleRows" :key="row.id">
虚拟列表会不断用新的数据替换可视窗口中的项目。稳定、唯一的 key 能帮助 Vue 正确复用节点和组件状态。不要使用会变化的随机数;如果数据顺序会变化,也不要无条件使用数组 index。
4.2 原文实现中的问题
原文代码的整体算法方向正确,但有一些细节需要修正:
itemHiehgt是拼写错误,应统一为itemHeight;window.onresize = ...和window.onorientationchange = ...会覆盖其他监听器,且没有卸载,应使用ResizeObserver或addEventListener并清理;@scroll.passive只能声明不会调用preventDefault,不能自动节流,仍需 rAF 或节流;- 高度、padding 和实际 CSS 行高必须一致,否则滚动位置会逐渐偏移;
- 触底请求需要 loading、hasMore、失败重试和请求去重,否则快速滚动可能重复请求;
require(...).default是 webpack/CJS 时代写法,Vite/ESM 项目通常使用静态 import 或 URL;- 原示例的
style="height: 100%"与 padding 方案容易造成高度计算混乱,使用总高度 + translate 更直观; - 只更新
startIndex不足以处理容器大小变化,需要 ResizeObserver 或重新计算。
五、VueUse 的现成方案
如果是固定高度列表,可以使用 VueUse 的 useVirtualList:
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'
const items = ref(
Array.from({ length: 100_000 }, (_, index) => ({
id: index,
text: `item-${index}`,
})),
)
const {
list,
containerProps,
wrapperProps,
} = useVirtualList(items, {
itemHeight: 50,
overscan: 8,
})
</script>
<template>
<div v-bind="containerProps" class="list-container">
<div v-bind="wrapperProps">
<div
v-for="{ data, index } in list"
:key="data.id"
class="list-row"
>
{{ index }} - {{ data.text }}
</div>
</div>
</div>
</template>
<style scoped>
.list-container {
height: 400px;
overflow: auto;
}
.list-row {
box-sizing: border-box;
height: 50px;
}
</style>
useVirtualList 的 itemHeight 可以是数字,也可以根据索引返回高度。使用第三方方案前仍要验证动态高度、横向滚动、键盘操作、SSR 和大数据量下的实际表现。
六、动态高度列表
固定高度是最容易实现的情况。对于动态高度:
scrollTop / 固定高度
✕
动态高度需要知道前面所有项目的累计高度
常见实现路线:
- 使用一个估计高度计算初始位置;
- 渲染后通过
ResizeObserver测量每一项; - 维护每项高度和累计高度;
- 滚动时用二分查找累计高度,定位第一个可见项;
- 高度变化后修正占位空间和滚动锚点;
- 处理图片加载、折叠展开和异步内容带来的二次测量。
动态高度虚拟列表要处理滚动跳动、定位误差、测量频率和锚点保持,不能简单把固定高度公式中的 itemHeight 换成平均值就认为完成了。
如果列表项高度差异很大或包含复杂交互,优先选择有动态高度支持的成熟库,并用真实数据测试。
七、分页、无限滚动和虚拟列表组合
典型的大数据页面可以这样设计:
服务端分页/筛选
↓
客户端只保留已加载数据
↓
虚拟列表只渲染可视区域
↓
IntersectionObserver 检测接近底部
↓
请求下一页并去重拼接
触底加载不是虚拟列表的一部分:
const loading = ref(false)
const hasMore = ref(true)
async function loadNextPage() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const result = await fetchPage(page.value + 1)
rows.value.push(...result.items)
hasMore.value = result.hasMore
page.value++
} finally {
loading.value = false
}
}
需要处理:
- 并发请求去重;
- 失败重试;
- 空列表和无更多数据;
- 服务端排序变化导致的重复项;
- 切换筛选条件时清空旧数据并取消旧请求;
- 用户返回页面时恢复滚动位置。
八、可访问性与交互边界
虚拟化会让屏幕阅读器和键盘用户只看到 DOM 中已渲染的部分,因此需要:
- 使用
role="list"、role="listitem"等合适语义; - 设置
aria-setsize、aria-posinset时保证值准确; - 保证滚动容器可以获得焦点和使用键盘;
- 删除或替换行时不要让焦点突然丢失;
- 对表格、树、组合框等复杂组件遵循对应 ARIA 模式;
- 如果无障碍要求高,先评估分页或普通列表是否更可靠。
虚拟列表不是 DOM 越少越好。它应该在性能收益和可访问性、SEO、复制搜索、浏览器查找之间取得平衡。对于不支持必要观察器、无障碍要求无法满足或数据量本来很小的设备/场景,应提供普通列表或分页降级,而不是强行虚拟化。
在 SSR 中,ResizeObserver、IntersectionObserver、requestAnimationFrame 等浏览器 API 只能在客户端生命周期中使用;服务端首屏应保留有意义的 HTML,不能依赖浏览器滚动事件才输出全部内容。
九、常见问题
9.1 虚拟列表为什么滚动时白屏
可能原因:
- overscan 太小;
- 滚动事件处理太慢;
- 每一项渲染成本太高;
- 图片或异步内容阻塞;
- 实际行高与计算行高不一致;
- 主线程被其他长任务占用。
可以增加 overscan、使用 rAF、降低行组件复杂度、固定图片尺寸,并通过 Performance 面板确认瓶颈。
9.2 为什么数组已经很大了还会卡
虚拟列表主要减少 DOM 和渲染成本,不能自动消除:
- 100 万条数据的 JSON 解析;
- 深层响应式转换;
- 全量筛选和排序;
- 大量图片下载;
- 组件缓存和业务对象占用的内存。
此时需要服务端分页、Web Worker、shallowRef、服务端筛选或数据结构优化。
9.3 虚拟列表能替代分页吗
不能。分页控制网络和数据规模,虚拟列表控制同时渲染的 DOM 数量。实际项目中通常组合使用。
十、总结
- 虚拟列表只渲染视口附近的数据,通过占位高度模拟完整滚动区域;
- 固定高度列表可以用
scrollTop / itemHeight快速计算索引; - overscan 可以减少快速滚动白屏,但过大也会增加渲染量;
ResizeObserver、rAF、稳定 key 和清理监听器是实现细节中的重点;- 动态高度需要测量、累计高度和滚动锚点修正,优先考虑成熟库;
- 分页、无限滚动、图片懒加载和虚拟列表解决不同问题,可以组合;
- 大数组解析、筛选、排序和网络请求仍需单独优化;
- 关注键盘、屏幕阅读器、焦点、SEO 和移动端滚动体验,不要只追求少渲染几个 DOM。
官方参考
原文出处
作者:Sunshine_Lin
来源:稀土掘金
本文保留原文康熙选秀比喻、固定高度公式和 Vue 虚拟列表实现,并补充 Vue 3、VueUse、动态高度、请求分页和无障碍边界。