两万行表格滚动像幻灯片:虚拟列表、白条与 key 的三个坑

产品要求一屏看完两万行订单,v-for 直接铺满,滚动只剩 8 帧。换成虚拟列表后,滚动白条、勾选串行、动态行高三个问题接连冒出来,最后用固定档位行高收场。

运营后台的订单列表页,产品经理提了一个需求:不要分页,一屏能看完所有订单,还要能对全量数据做搜索和排序。她的原话是「我要像 Excel 那样往下滚」。当时我以为这是个前端小改动,把 pageSize 从 20 改成 20000 就完事了。

上线后第一周,运营那边反馈:滚动的时候卡,往下拉能看到空白,等两秒才出内容。我拿他们的账号试了一次,滚轮一推,画面像翻幻灯片一样一帧一帧地走,任务管理器里那个标签页的内存从 180MB 一路涨到 1.1GB。

两万行 DOM,每行 6 个单元格,加起来二十多万个节点。这不是「优化一下」的问题,是方案本身不成立。

一、先量一下:滚动时主线程在干什么

改方案之前我先抓了一份 Performance 面板的火焰图,因为「卡」有很多种,方向搞错就白改。

录制一段三秒的滚动,帧率常驻在 6 到 10 fps,每一帧里主线程时间大致分成三块:Recalculate Style 占四成,Layout 占三成,剩下是 Paint 和 Composite。JS 执行为零——Vue 的响应式没有任何更新,滚动本身也不触发数据变化。

这个结论很关键:瓶颈不在 JS,在浏览器要把二十万个节点重新算一遍样式和布局。滚动时哪怕只有一部分行进入视口,Chrome 也要重新计算受影响的行,而行与行之间没有隔离,布局会把整张表拖下水。

于是方向明确了:减少 DOM 节点数量,让浏览器每次只需要处理几十行。这就是虚拟列表。

二、虚拟列表的三个数

原理说穿了很简单:容器固定高度、内部撑一个「假高度」的元素让滚动条看起来正常、只渲染当前视口内的行,用 transform 把它们挪到正确位置。

要算的只有三个数:

变量 含义 计算方式
startIndex 第一条要渲染的行 Math.floor(scrollTop / rowHeight)
visibleCount 视口内能放几行 Math.ceil(viewportHeight / rowHeight)
offsetY 渲染区相对容器的位移 startIndex * rowHeight

再加上两端各若干行的缓冲(buffer),避免快速滚动时出现空白。缓冲不是越多越好,太多就退化成普通列表了。

三、固定行高版本

第一版按固定行高做,40px 一行,容器 800px 高,所以视口内 20 行,上下各留 10 行缓冲,最多渲染 40 行。

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  rows: { type: Array, default: () => [] },
  rowHeight: { type: Number, default: 40 },
  buffer: { type: Number, default: 10 },
})

const viewportRef = ref(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)

const startIndex = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.buffer)
)
const endIndex = computed(() =>
  Math.min(
    props.rows.length,
    Math.ceil((scrollTop.value + viewportHeight.value) / props.rowHeight) + props.buffer
  )
)
const visibleRows = computed(() => props.rows.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.rowHeight)
const totalHeight = computed(() => props.rows.length * props.rowHeight)

let ticking = false
function onScroll(e) {
  // 滚动事件每秒能触发上百次,用 rAF 把更新压到每帧一次
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    scrollTop.value = e.target.scrollTop
    ticking = false
  })
}

function measure() {
  viewportHeight.value = viewportRef.value?.clientHeight || 0
}
onMounted(() => {
  measure()
  window.addEventListener('resize', measure)
})
onUnmounted(() => window.removeEventListener('resize', measure))
</script>

<template>
  <div ref="viewportRef" class="vlist" @scroll.passive="onScroll">
    <div class="vlist-phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="vlist-body" :style="{ transform: `translateY(${offsetY}px)` }">
      <div v-for="row in visibleRows" :key="row.id" class="vlist-row"
           :style="{ height: rowHeight + 'px' }">
        <slot name="row" :row="row" />
      </div>
    </div>
  </div>
</template>

<style scoped>
.vlist { position: relative; height: 100%; overflow-y: auto; }
.vlist-phantom { width: 1px; }
.vlist-body { position: absolute; top: 0; left: 0; right: 0; will-change: transform; }
.vlist-row { box-sizing: border-box; }
</style>

换上去之后,滚动稳定在 55 到 60 fps,内存回到 200MB 出头,DOM 节点数从二十多万降到三千以内。这一步的收益是数量级的,不需要任何微调技巧。

四、第一版为什么会出现白条

上线第二周又有人反馈:快速用鼠标滚轮猛推时,中间会闪一条白。

原因是我只监听 scroll,而滚动事件是「已经滚过去了」才触发,浏览器在两次事件之间可能已经绘制了新的位置。transform 的更新要排队到下一帧,两者一错位,用户就看到空白。

修法有两个方向。一是加大缓冲,但停下来之后仍然会闪,只是概率低一些。二是让渲染区域跟随滚动实时偏移,而不是等 scrollTop 更新后再改 transform:

function onScroll(e) {
  const el = e.target
  // 先用当前的 scrollTop 立刻算一次位移,把白条盖掉
  const rawStart = Math.floor(el.scrollTop / props.rowHeight)
  const keepStart = Math.max(0, rawStart - props.buffer)
  offsetY.value = keepStart * props.rowHeight
  // 再在下一帧更新真正要渲染的区间
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    startIndexOverride.value = keepStart
    scrollTop.value = el.scrollTop
    ticking = false
  })
}

更简单的办法是干脆不用 scroll 事件,改用 IntersectionObserver 观察一个「哨兵」元素,或者干脆让渲染区多渲染两屏。我们最后用的是「缓冲按屏算」——把 buffer 从 10 行改成 visibleCount(也就是一屏),白条彻底消失,代价是 DOM 多了一倍,但基数小,无所谓。

五、key 用 index,选中态串了

改成虚拟列表之后,勾选功能出问题了:勾第二行的复选框,第三行也被勾上了;往下滚一屏再滚回来,之前勾的全没了。

这两个现象都指向同一个原因——v-for 的 :key 用的是索引。第一版代码里我写的是 :key="index",在普通列表里这没什么问题,但在虚拟列表里,index 是「渲染数组里的位置」,不是「数据的身份」。滚动时 startIndex 变化,同一个 index 对应的可能是完全不同的数据行,Vue 就复用了旧的 DOM 和状态。

改成 :key="row.id" 之后勾选正常了,但「滚回去勾选消失」还在。这次不是 key 的问题,是状态没地方存——我把 selected 存在了行组件的 ref 里,行被销毁,状态就没了。

正确的做法是把选中态提到父组件的 Set 里,行组件只做受控渲染。这个道理在普通列表里也成立,只是普通列表不销毁行,所以问题被掩盖了。虚拟列表会把「状态该放在哪」这件事放大到无法忽视。

六、动态行高:为什么我最后放弃了

订单列表有几个字段长度不定,备注可能一行也可能五行,固定 40px 会截断。产品要求「行高自适应内容」。

我试过三种方案。

第一种是渲染后用 ResizeObserver 量每一行的真实高度,缓存到 Map 里,再累加出前缀和数组做二分查找。技术上可行,但有两个问题:未渲染的行没有高度,只能用估算值,估算错了滚动条会跳;而且每次测量都触发一次布局,滚动时反复测量直接把性能吃回去了。

第二种是「按内容长度估算行高」,比如每 30 个字加一行。实现简单,但估算误差累积起来,滚到一万行之后位置能偏出好几屏。

第三种是限定两档行高:普通行 40px,有备注的行 80px,由数据本身决定,不需要测量。这个方案牺牲了「完全自适应」,但换来了确定性——高度在渲染前就知道,前缀和数组一次算完,滚动时零测量。

我们最后用的是第三种。产品那句「行高自适应」在实现层面被翻译成了「备注最多显示两行,超出的部分用 tooltip」。这是一个妥协,但它让整个方案从「需要精细调优」变成了「结构上就不会错」。

小结

  • 万行级列表的性能瓶颈通常不在 JS,而在浏览器对海量 DOM 的样式重算与布局,先看火焰图再决定方向。
  • 虚拟列表只需要三个数:起始索引、可视数量、位移量,配合缓冲行就能覆盖绝大多数场景。
  • scroll 事件有「已滚过才触发」的固有延迟,只靠它更新 transform 会出现白条,缓冲要按屏算而不是按行算。
  • 虚拟列表里 :key 必须是数据的唯一标识,用索引会导致行状态错位;行内状态要提到父级,因为行会被销毁。
  • 动态行高如果要精确,就得测量,而测量会把性能吃回去;用「有限档位 + 数据决定高度」是更稳的工程取舍。
  • 虚拟列表只是渲染层优化,全量搜索排序仍然要在数据层做,两万行在浏览器里做全量排序同样会卡。
查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。