背景与问题界定

在后台管理系统中,数据看板和日志查询面板经常需要渲染数千甚至数万条记录。直接使用 v-for 渲染全部 DOM 节点会导致首次渲染耗时数百毫秒、滚动严重掉帧,Chrome DevTools 的 Performance 面板会清晰呈现主线程长时间被 Layout 和 Paint 占据的场景。虽然各大组件库均提供了虚拟滚动方案,但实际项目中总有定制需求——行高动态变化、表头冻结、分组头部、行内展开详情等,通用组件往往在这些场景下力不从心。我们需要自建一套适配业务场景的虚拟列表方案,覆盖从 1 万到 100 万条数据的渲染需求。

目标拆解与工程约束

  • DOM 节点数恒定在可视区 3 倍以内:无论数据总量多少,实际渲染的 DOM 节点数必须控制在可视行数的 2 ~ 3 倍,超出部分通过 padding-top/padding-bottom 占位。
  • 支持动态行高:无法预先获知每行高度时,必须提供高度缓存与估算回退机制,避免滚动时出现大幅跳跃。
  • 滚动位置恢复:用户离开列表页再返回时,应恢复滚动位置和展开状态,避免频繁重新请求全部数据。
  • 兼容 keep-alivetransition-group:虚拟列表内部不冲突 Vue 的缓存机制,支持列表项的入场动画(如 fadeIn)。

方案设计

虚拟列表的核心思想是只渲染可视区域及其上下缓冲区内的节点。我们实现一个通用的 useVirtualList 组合函数,接收数据源和配置项,返回可视数据切片和容器样式绑定。

基础架构分为三层:容器层负责监听滚动事件、计算可视范围;数据层维护扁平化的行索引与高度映射;渲染层通过 v-for 仅迭代 visibleItems。对于固定高度场景,实现最简单——用行高乘以总行数算出总高度,计算 scrollTop / rowHeight 得到起始索引。动态高度场景则需要一个 高度缓存表:首次渲染时所有行按估算高度占位,渲染完成后通过 ResizeObservergetBoundingClientRect 获取真实高度并更新缓存。

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 }
}

对于百万级场景,computedtotalHeight 求和可能成为瓶颈,需要使用 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、滚动速度过快导致白屏闪烁。这些边界在通用组件库中已经被踩平,但在自实现方案中必须从头覆盖。值得投入精力的是高度缓存算法的精度与性能之间的平衡:过于激进的估算会导致抖动,过于保守则逼近全量渲染。最优解往往是业务特征与通用策略的结合。