背景与问题界定
在后台管理系统中,数据看板和日志查询面板经常需要渲染数千甚至数万条记录。直接使用 v-for 渲染全部 DOM 节点会导致首次渲染耗时数百毫秒、滚动严重掉帧,Chrome DevTools 的 Performance 面板会清晰呈现主线程长时间被 Layout 和 Paint 占据的场景。虽然各大组件库均提供了虚拟滚动方案,但实际项目中总有定制需求——行高动态变化、表头冻结、分组头部、行内展开详情等,通用组件往往在这些场景下力不从心。我们需要自建一套适配业务场景的虚拟列表方案,覆盖从 1 万到 100 万条数据的渲染需求。
目标拆解与工程约束
- DOM 节点数恒定在可视区 3 倍以内:无论数据总量多少,实际渲染的 DOM 节点数必须控制在可视行数的 2 ~ 3 倍,超出部分通过
padding-top/padding-bottom占位。 - 支持动态行高:无法预先获知每行高度时,必须提供高度缓存与估算回退机制,避免滚动时出现大幅跳跃。
- 滚动位置恢复:用户离开列表页再返回时,应恢复滚动位置和展开状态,避免频繁重新请求全部数据。
- 兼容
keep-alive和transition-group:虚拟列表内部不冲突 Vue 的缓存机制,支持列表项的入场动画(如 fadeIn)。
方案设计
虚拟列表的核心思想是只渲染可视区域及其上下缓冲区内的节点。我们实现一个通用的 useVirtualList 组合函数,接收数据源和配置项,返回可视数据切片和容器样式绑定。
基础架构分为三层:容器层负责监听滚动事件、计算可视范围;数据层维护扁平化的行索引与高度映射;渲染层通过 v-for 仅迭代 visibleItems。对于固定高度场景,实现最简单——用行高乘以总行数算出总高度,计算 scrollTop / rowHeight 得到起始索引。动态高度场景则需要一个 高度缓存表:首次渲染时所有行按估算高度占位,渲染完成后通过 ResizeObserver 或 getBoundingClientRect 获取真实高度并更新缓存。
export function useVirtualList<T>(items: Ref<T[]>, options: VirtualOptions) {
const { itemHeight: estimatedHeight, buffer = 2 } = options
const containerRef = ref<HTMLElement | null>(null)
const heights = new Map<number, number>()
const totalHeight = computed(() =>
items.value.reduce((sum, _, i) => sum + (heights.get(i) ?? estimatedHeight), 0)
)
const startIndex = computed(() => findStartIndex(containerRef.value!.scrollTop, heights, estimatedHeight))
const visibleCount = computed(() => Math.ceil(containerRef.value!.clientHeight / estimatedHeight) + buffer)
const visibleItems = computed(() =>
items.value.slice(startIndex.value, startIndex.value + visibleCount.value)
)
// 滚动时同步更新偏移量
const offsetY = computed(() => sumHeights(heights, startIndex.value, estimatedHeight))
return { containerRef, visibleItems, totalHeight, offsetY, updateHeight }
}
对于百万级场景,computed 的 totalHeight 求和可能成为瓶颈,需要使用 shallowRef + 手动触发更新替代全量响应式追踪,同时将高度缓存表升级为 分段树(Segment Tree) 以支持 O(log n) 的索引查找。
实施路径与关键决策
- 先实现固定高度版本,再扩展动态高度:固定高度版本代码量仅 100 行,快速上线验证方案可行性。
- 动态高度采用 ResizeObserver 监听:避免 scroll 事件中频繁测量 DOM,ResizeObserver 回调中更新高度缓存表即可。
- 滚动恢复方案:在
beforeRouteLeave中将scrollTop存入 sessionStorage,onMounted时恢复并挂载nextTick等待 DOM 更新。 - 大数据量触发虚拟滚动 + 分页接口:前端虚拟列表与后端分页结合——总条数由后端返回,可见区数据按需请求,避免前端一次持有全部数据。
验证指标与可持续迭代
使用 Chrome DevTools 的 Performance 面板录制滚动操作:固定高度 10 万条数据下帧率稳定在 60fps,首次渲染 < 50ms。动态高度 1 万条场景下首次滚动无闪烁,实测 getBoundingClientRect 次数控制在可视行数的 2 倍以内。后续迭代方向包括:横向虚拟滚动(列数过多的表格)、虚拟树组件(TreeView 的展开折叠)和基于 content-visibility 的 CSS 原生虚拟化降级方案。
工程落地思考
虚拟列表是"你不需要渲染所有东西"这一朴素真理在工程中的最佳体现。实现过程中最容易犯的错误是忽视边界条件——空数据、仅 1 条数据、容器尺寸为 0、滚动速度过快导致白屏闪烁。这些边界在通用组件库中已经被踩平,但在自实现方案中必须从头覆盖。值得投入精力的是高度缓存算法的精度与性能之间的平衡:过于激进的估算会导致抖动,过于保守则逼近全量渲染。最优解往往是业务特征与通用策略的结合。