外观
从3s到180ms:万级长列表虚拟列表+Web Vitals优化实战
问题背景:万级数据表格的性能红线突破
在后台管理系统、数据展示类站点的开发中,我们经常会遇到单页渲染万级甚至十万级数据表格的需求。这类页面通常承载着设备巡检、订单明细、日志审计、风控报表等典型业务,用户既希望像 Excel 一样通过连续滚动快速定位数据,又要求搜索、排序、行展开等交互保持即时响应。如果直接采用全量 DOM 渲染的常规写法,浏览器需要在同一个文档中创建上万个包含多层单元格的行节点,首次渲染时间往往超过 3s,不仅 LCP(最大内容绘制)指标直接突破 Web Vitals 规定的 2.5s 良好阈值,快速滚动时还会出现明显的掉帧卡顿,INP(交互到绘制延迟)超过 200ms,CLS(布局偏移)也会因为后续 DOM 批量插入出现大幅波动。
从浏览器渲染管线分析,卡顿并不是单一的 JavaScript 执行问题,而是布局、绘制、合成多个阶段共同被压垮:1.2 万个 <tr> 往往意味着 10 万个以上的单元格节点,初始 HTML 解析、样式匹配、布局树构建和层提升都会线性增长;滚动时浏览器还要重新计算命中区域、触发行样式变更和垃圾回收,低端设备上单次滚动事件的主线程占用很容易超过一帧 16.67ms 的预算。
我们在某 VitePress 驱动的内部数据文档站开发时就遇到了典型问题:单页嵌入的 1.2 万行设备巡检数据表,首次白屏到完全可交互耗时 3200ms,滚动时页面帧率掉到 20 帧以下,完全不符合生产环境的性能要求。最初尝试了分页、滚动触底懒加载分页等方案,要么破坏用户连续浏览的体验,要么滚动后期 DOM 累积超过 5000 个依然出现卡顿,最终我们选择虚拟列表结合 Web Vitals 全链路指标监控的方案落地优化,最终首次渲染耗时降到 180ms,所有 Web Vitals 核心指标全部达到优秀标准。
这里的性能红线可以量化为三条:首屏 LCP 不超过 2.5s;交互 INP 不超过 200ms;CLS 控制在 0.1 以内。对于表格类页面,还应额外关注滚动帧率、DOM 节点总数、单次滚动回调耗时和内存占用。实测中,全量渲染方案的节点数超过 12 万,页面堆内存峰值接近 180MB;虚拟列表稳定后节点数仅约 4000,内存峰值下降到 65MB 左右,这也是滚动能够长期稳定在 60fps 的关键原因。
方案对比:三类长列表优化路径的选型决策
我们对市面主流的三类长列表优化方案做了实测对比,数据基于 1.2 万行测试数据集得出。测试环境统一为 M 系列芯片笔记本的 Chrome 桌面版、4 倍 CPU 降速、Fast 3G 网络模拟,并补充了一台 4GB 内存 Android 中端机作为低端设备样本。每组方案连续测量 10 次,去掉最高和最低值后取平均,避免单次 GC 或系统调度造成误判。
- 常规分页方案:DOM 渲染数始终小于 100,性能最优,但用户连续查看全量数据需要反复翻页,操作路径增加 6 次以上,业务侧无法接受。其优点是实现最简单、SEO 和首屏稳定性较好;缺点是上下文割裂,跨页对比、浏览器内查找、连续复制数据都不方便。对于“按条件查询后逐条审核”的业务,分页会显著增加认知负担。
- 滚动触底懒加载方案:DOM 数随滚动逐步累积,滚动到末尾时 DOM 总数超过 1.2 万,首次渲染耗时优化到 800ms,但滚动到后期依然出现明显卡顿,CLS 指标因为每次插入新行出现 0.3 以上的大幅偏移,不符合 Web Vitals 要求。如果配合接口分页,还会出现请求竞态、回滚位置丢失、加载指示器挤压布局等问题。虽然可以通过
IntersectionObserver和骨架屏改善体验,但无法解决节点无限累积的根因。 - 虚拟列表方案:仅渲染可视区域内的 DOM 元素,全生命周期 DOM 总数始终控制在 30~50 行区间,首次渲染耗时优化到 200ms 以内,滚动全程帧率稳定在 60 帧,所有 Web Vitals 指标都能达到优秀阈值,是本次需求的最优解。其代价是实现复杂度更高,需要处理动态行高、滚动锚定、键盘定位、列宽同步和索引边界;但在数据高度结构化、行高固定的设备巡检表格中,这些风险都可控。
最终我们确定了「自定义轻量虚拟列表实现 + Web Vitals 全链路指标埋点校验」的落地方案,不需要引入第三方重型虚拟列表库,构建后 gzip 包体积增量不到 2KB。相比直接引入 vue-virtual-scroller、@tanstack/vue-virtual 等库,自研方案更适合 VitePress 场景:一是可以精确控制容器样式,避免第三方组件默认样式与 Markdown 表格主题冲突;二是固定行高场景并不需要完整的动态测量能力;三是问题排查链路更短,后续接入埋点和键盘导航时无需理解库内部抽象。
选型时还需要注意一个常见误区:虚拟列表不是“只把数据切片渲染”这么简单。如果切片计算、响应式依赖和滚动监听没有做精细化控制,频繁触发全量组件重新渲染,仍可能造成明显抖动。因此,方案中必须同时满足四个条件:稳定的总高度、可视区外的上下缓冲、基于 transform 的内容偏移、滚动回调中的同步读写分离。
实操步骤与代码示例
1. Vue3 虚拟列表核心实现
我们基于 Vue3 + TypeScript 实现适配表格场景的虚拟列表组件,核心逻辑是监听滚动事件,实时计算可视区域对应的数据源索引,仅渲染对应区间的行数据,通过上下占位元素撑起总高度,实现滚动条高度和全量列表完全一致。固定行高是该实现的前提,如果业务存在自动换行或动态详情展开,应先将行高规范化,或升级为动态测量版本。
下面示例采用 padding-top 与 padding-bottom 撑开总滚动高度,可视数据通过 transform: translateY 移动到正确位置。缓冲行数 buffer 用于覆盖快速滚动时的渲染空窗,通常取可视行数的 1~1.5 倍。以每行 44px、容器视口 600px 计算,可视行约 14 行,加上下各 8 行缓冲,实际渲染 30 行左右即可覆盖绝大多数快速滚动场景。
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
interface VirtualTableProps<T> {
items: T[]
rowHeight?: number
visibleCount?: number
buffer?: number
}
const props = withDefaults(
defineProps<VirtualTableProps<Record<string, unknown>>>(),
{
rowHeight: 44,
visibleCount: 14,
buffer: 8,
},
)
const scrollContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.rowHeight)
const startIndex = computed(() => {
return Math.max(
0,
Math.floor(scrollTop.value / props.rowHeight) - props.buffer,
)
})
const endIndex = computed(() => {
const visibleEnd = Math.ceil(
(scrollTop.value + props.visibleCount * props.rowHeight) / props.rowHeight,
)
return Math.min(props.items.length, visibleEnd + props.buffer)
})
const visibleItems = computed(() => {
return props.items
.slice(startIndex.value, endIndex.value)
.map((item, offset) => ({
item,
index: startIndex.value + offset,
}))
})
const offsetY = computed(() => startIndex.value * props.rowHeight)
const bottomHeight = computed(() => {
return Math.max(
0,
totalHeight.value - endIndex.value * props.rowHeight,
)
})
function handleScroll(event: Event) {
scrollTop.value = (event.target as HTMLElement).scrollTop
}
</script>
<template>
<div
ref="scrollContainer"
class="virtual-scroll"
@scroll.passive="handleScroll"
>
<div
class="virtual-phantom"
:style="{ height: `${totalHeight}px` }"
>
<div
class="virtual-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="row in visibleItems"
:key="row.index"
class="virtual-row"
:style="{ height: `${rowHeight}px` }"
>
<span class="cell-index">{{ row.index + 1 }}</span>
<span class="cell-device">{{ row.item.deviceName }}</span>
<span class="cell-status">{{ row.item.status }}</span>
</div>
</div>
<div :style="{ height: `${bottomHeight}px` }" />
</div>
</div>
</template>
<style scoped>
.virtual-scroll {
height: 600px;
overflow-y: auto;
contain: strict;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.virtual-phantom {
position: relative;
width: 100%;
}
.virtual-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
}
.virtual-row {
display: grid;
grid-template-columns: 80px 1fr 120px;
align-items: center;
padding: 0 16px;
box-sizing: border-box;
border-bottom: 1px solid var(--vp-c-divider);
}
</style>这段代码有三个关键点。第一,key 必须使用稳定的数据主键或全局索引,不能使用数组在切片中的临时下标,否则滚动复用节点时会发生组件状态错位。第二,滚动监听使用 passive,避免浏览器因为等待 preventDefault() 而阻塞滚动合成。第三,外层容器添加 contain: strict,将布局、绘制和样式变化限制在组件内部,实测在低端 Android 设备上可将滚动期间的布局耗时再降低约 12%~18%。
如果需要进一步降低高频滚动带来的响应式开销,可以使用 requestAnimationFrame 合并同一帧内的多次 scrollTop 更新:
ts
let frameId = 0
let latestScrollTop = 0
function scheduleUpdate(top: number) {
latestScrollTop = top
if (frameId) return
frameId = requestAnimationFrame(() => {
scrollTop.value = latestScrollTop
frameId = 0
})
}
function handleScroll(event: Event) {
scheduleUpdate((event.target as HTMLElement).scrollTop)
}在 120Hz 设备上,浏览器事件触发频率可能高于界面实际刷新频率,rAF 合帧能够避免重复计算。需要注意不要引入防抖或大于一帧的节流,否则会出现滚动条已移动、内容延迟刷新的白屏感。
避坑清单如下:
- 不要对整个
items数组做深度响应式转换。VitePress 构建时注入的大型静态数据可使用shallowRef或markRaw,减少 Proxy 代理成本。 - 不要把行内容写成默认
min-height: auto的弹性布局,图片加载或文本换行可能导致实际高度与计算高度不一致。 - 不要在滚动回调中读取
getBoundingClientRect()后又立刻写入样式,这会触发布局抖动;固定行高下应只读取scrollTop。 - 不要省略上下缓冲。缓冲为 0 时,快速触控板滑动或惯性滚动会短暂出现空白行。
- 不要在虚拟行内部保留未清理的定时器、事件监听和 ResizeObserver,否则节点复用时会产生内存泄漏。
- 表格列头需要独立于滚动内容固定,并在横向滚动时与 body 同步
scrollLeft,否则会出现表头和列错位。
2. 数据加载与组件接入
在 VitePress 中,数据可能来自 Markdown 构建期导入的 JSON,也可能来自运行时接口。对于 1.2 万行以内、字段简单的静态数据,优先构建期生成并按需静态导入;对于超过 10 万行或字段包含长文本的数据,建议使用分片 JSON、Fetch Stream 或接口按范围查询。下面示例展示如何安全接入静态数据:
ts
import { shallowRef } from 'vue'
// Vite 支持 JSON 直接导入;大数组应避免被深度 reactive 代理
import rawRecords from './device-inspection.json'
interface DeviceRecord {
id: string | number
deviceName: string
status: string
inspectedAt: string
}
const records = shallowRef<DeviceRecord[]>([])
async function bootstrap() {
const start = performance.now()
// 若数据量更大,可替换为 fetch('/data/device-inspection.json')
records.value = rawRecords as DeviceRecord[]
// 上报本地数据准备耗时,便于区分数据解析与渲染成本
performance.measure('table-data-ready', { start })
}
void bootstrap()父组件接入时只需要传入数据和行高:
vue
<script setup lang="ts">
import { shallowRef } from 'vue'
import VirtualTable from './VirtualTable.vue'
const records = shallowRef<DeviceRecord[]>([])
</script>
<template>
<VirtualTable
:items="records"
:row-height="44"
:visible-count="14"
:buffer="8"
/>
</template>实战中建议将数据准备、首次绘制、用户可滚动三个阶段分别打点。很多优化只统计了组件渲染时间,却忽略了 JSON 解析和大对象初始化。我们的样本数据约 6.8MB,普通深度响应式转换需要 120ms 左右;改用 shallowRef 后下降到 18ms,收益非常明显。
3. Web Vitals 全链路埋点校验
性能优化不能只依赖开发机的主观感受,必须通过真实用户监控形成闭环。推荐使用 Google 官方 web-vitals 库采集 LCP、INP、CLS,并补充自定义的首屏表格渲染耗时和滚动帧率。VitePress 项目中应将埋点代码放在客户端入口或组件挂载后的非关键路径,避免监控脚本反过来影响首屏。
ts
import { onCLS, onINP, onLCP } from 'web-vitals'
type MetricName = 'CLS' | 'INP' | 'LCP'
interface VitalMetric {
name: MetricName
value: number
rating: string
id: string
navigationType: string
}
function sendMetric(metric: VitalMetric) {
const payload = JSON.stringify({
page: location.pathname,
name: metric.name,
value: Number(metric.value.toFixed(2)),
rating: metric.rating,
navigationType: metric.navigationType,
})
// 生产环境可替换为 sendBeacon,页面卸载时也能可靠发送
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/perf', new Blob([payload], {
type: 'application/json',
}))
} else {
fetch('/api/perf', {
method: 'POST',
body: payload,
keepalive: true,
credentials: 'include',
}).catch(() => {})
}
}
onLCP(sendMetric)
onINP(sendMetric)
onCLS(sendMetric)为了确认虚拟列表本身是否达到目标,可以增加自定义指标:
ts
import { onBeforeUnmount, onMounted } from 'vue'
function useScrollFps(container: () => HTMLElement | undefined | null) {
let frames = 0
let begin = 0
let rafId = 0
function loop(timestamp: number) {
if (!begin) begin = timestamp
frames += 1
const elapsed = timestamp - begin
if (elapsed >= 1000) {
const fps = Math.round((frames * 1000) / elapsed)
window.dispatchEvent(new CustomEvent('table-scroll-fps', {
detail: { fps },
}))
frames = 0
begin = timestamp
}
rafId = requestAnimationFrame(loop)
}
onMounted(() => {
const el = container()
if (!el) return
el.addEventListener('scroll', () => {}, { passive: true })
rafId = requestAnimationFrame(loop)
})
onBeforeUnmount(() => cancelAnimationFrame(rafId))
}上线后建议按设备等级、网络类型、页面路径、数据量四个维度聚合,而不是只看平均值。平均值很容易被高端设备掩盖:一次真实数据中,桌面端平均 LCP 为 0.9s,但低端 Android 机为 2.8s;将缓冲从 4 行提高到 8 行、减少行内复杂阴影后,低端机 LCP 降到 1.9s,滚动平均帧率从 47fps 提升到 58fps。
最终验收数据如下:首屏 DOM 行节点从 1.2 万行下降到 30~50 行;首次渲染耗时从 3200ms 降到 180ms;滚动过程中主线程单帧任务基本控制在 8ms 以内;CLS 从批量插入时的 0.3+ 降至 0.02 以下;INP 从超过 200ms 降至约 60ms;LCP 稳定在 1.2s 内。更重要的是,这些指标不是实验室环境中的一次性结果,而是通过 RUM 埋点在真实用户访问中持续可观测、可告警、可回归验证。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。