16 KiB
list-vxe 500条数据渲染卡顿性能分析
背景
PC端列表在将分页从20条切换到500条时,渲染卡顿约8-10秒。后端API响应仅660ms,36列 + 373条数据量不大,未开启虚拟滚动也不应如此卡顿。
实测数据
第一轮(2026-03-12):粗粒度定位
测试条件:374 rows × 37 columns = 13,838 cells,分页从20切换到500。
[perf] fetch::unknownNameCheck: 1.46ms
[perf] fetch::dataProcessing: 1.73ms
[perf] fetch::assignData: 2.77ms
[perf] useCacheComputed#3::stableStringify: 17.74ms + 13.06ms
[perf] useCacheComputed#3::func + isEqual: 0.11ms → update
[perf] useMergeCells::total: 11.58ms (0 merge rules)
[perf] useCacheComputed#4::stableStringify: 12.14ms
[perf] useCacheComputed#4::func + isEqual: 0.30ms → update
[perf] vxeGrid::render: 24,428.40ms ⚠️
结论: JS 计算层 (~56ms) 不是瓶颈,vxe-grid 渲染 (24.4s) 占 99.75%。
第二轮(2026-03-12):细粒度定位 vxe-grid 内部
在 getCellRender 中包装所有 slot 函数,测量单元格渲染函数的调用次数与耗时。
[perf] vxeGrid: data=20 rows, columns=37 cols, key=37 (切换前)
[perf] cellSlot: 700 calls, total 8.5ms, avg 0.012ms/call (切换前,20行正常)
[perf] vxeGrid: data=374 rows, columns=37 cols, key=37 (切换后)
[perf] cellSlot: 65,450 calls, total 1,055.7ms, avg 0.016ms/call
[perf] vxeGrid::render: 55,310.5ms ⚠️
VxeTable 未触发 onUnmounted/onMounted(:key=37 前后不变,未发生销毁重建)。
第三轮(2026-03-12):按轮次追踪渲染触发源
在 slot 包装器中加入 console.trace 追踪每轮首次调用的触发栈,并按轮次分别统计。
[perf] vxeGrid: data=20 rows, columns=37 cols, key=37 (loading=true 阶段)
[perf] cellSlot round#2: 700 calls, slotJs=6.9ms, wallTime=631.5ms
→ trigger: useCacheComputed#3 set value → VxeTable body renderVN
Timer '[perf] vxeGrid::render' already exists (两次 useCacheComputed 串联更新)
[perf] vxeGrid: data=375 rows, columns=37 cols, key=37 (数据到达)
[perf] cellSlot round#3: 39,375 calls, slotJs=528.6ms, wallTime=36,563.1ms
→ trigger: useCacheComputed#4 set value → VxeTable body renderVN
[perf] vxeGrid::render: 24,613.5ms
ResizeObserver → recalculate 未出现。
各轮次分析
| 轮次 | 触发源 | 数据量 | slot 调用次数 | slotJs | wallTime |
|---|---|---|---|---|---|
| round#2 | useCacheComputed#3 (vxeTableProps) | 20 rows | 700 | 6.9ms | 631.5ms |
| round#3 | useCacheComputed#4 (gridProps) | 375 rows | 39,375 | 528.6ms | 36,563ms |
关键发现
- 只有 1 轮有效渲染(round#3),不是多轮重复渲染。round#2 是 loading 阶段的过渡渲染(仍为旧 20 行数据)
- slot 函数本身很快:平均 0.013ms/次,528.6ms 总计,仅占 wallTime 的 1.4%
- vxe-grid 内部 + DOM 操作占 98.6%:36,563ms wallTime - 528.6ms slotJs ≈ 36,034ms
- 每个单元格 slot 被调用 ~2.84 次:39,375 / (375×37) = 2.84。部分是 header cell 渲染,部分可能是 vxe-grid 内部测量
:key未触发重建:key=37 不变- ResizeObserver recalculate 未触发:不是重复渲染的原因
- 两个 useCacheComputed 串联导致了 2 次 gridProps 更新(round#2 + round#3),其中 round#2 是浪费的过渡渲染
根因确认
瓶颈是 vxe-grid 在无虚拟滚动下渲染 375×37=13,875 个 DOM 单元格的固有开销。 这不是我们的代码问题,而是 vxe-grid 框架在全量 DOM 渲染模式下的性能限制。
两个 useCacheComputed 的串联更新造成了额外的 1 次过渡渲染(round#2),但主要开销在 round#3。
数据流:分页切换时的完整触发链
用户切换分页 500条
→ currentPageSize 变化
→ useOffsetPagination.onPageSizeChange → getTableData (debounce 50ms)
→ API 请求 (660ms)
→ data.value = records (shallowRef 赋值)
┌──────────────────────────────────────────────────┐
│ 以下全部在主线程执行,互相串联 │
├──────────────────────────────────────────────────┤
│ ① vxeTableProps useCacheComputed 重算 (~31ms) │
│ → stableStringify → debounce 30ms → func+isEqual│
│ → vxeTableProps.value 更新 │
│ │
│ ② VxeTable 接收新 props │
│ → useMergeCells computed 同步重算 (~12ms) │
│ │
│ ③ gridProps useCacheComputed 重算 (~12ms) │
│ → stableStringify → debounce 30ms → func+isEqual │
│ → gridProps.value 更新 │
│ │
│ ④ vxe-grid 接收新 props → 渲染 13,838 个单元格 │
│ ⚠️ 耗时 24,428ms ← 唯一瓶颈! │
└──────────────────────────────────────────────────┘
已排除的可能原因
| 可能原因 | 排查结果 |
|---|---|
| slot 渲染函数(renderItem)本身慢 | ❌ 排除。0.013ms/次,528ms 总计,仅占 1.4% |
:key="columns.length" 导致重建 |
❌ 排除。key=37 前后不变,未触发 onUnmounted |
| ResizeObserver recalculate 触发二次渲染 | ❌ 排除。recalculate 日志未出现 |
| 多轮重复渲染 | ❌ 排除。只有 1 轮有效渲染(round#3),无多轮 |
| stableStringify / useMergeCells 等 JS 计算 | ❌ 排除。总计 ~56ms,可忽略 |
已确认的根因
vxe-grid 在无虚拟滚动下渲染 375×37 个 DOM 单元格的固有开销 = ~36 秒
375 行 × 37 列 = 13,875 单元格 → 每个单元格包含 <td> + <div.vxe-cell> + slot 内容 ≈ 3-5 个 DOM 节点
总计约 42,000-70,000 个 DOM 节点的创建、插入、样式计算、布局。
额外开销:两个 useCacheComputed 串联导致 1 次过渡渲染(round#2,旧 20 行数据),浪费 ~631ms。
第四轮(2026-03-12):自定义渲染 vs 纯文本对比 + 按类型拆解
4a. 纯文本 vs 自定义渲染对比
将 getCellRender 中所有 slot 替换为 <span>{原始值}</span>,对比渲染耗时。
| 模式 | vxeGrid::render | 减去基线 | 占比 |
|---|---|---|---|
纯文本 <span> |
3,221ms | 基线 | - |
| 自定义渲染(正常) | 23,577ms | ~20,356ms | 100% |
结论:自定义渲染的 VNode/DOM 复杂度贡献了 ~20s(86%)的渲染开销。
4b. 按列类型拆解
选择性启用某一类列的自定义渲染,其余用纯文本,对比耗时。
| 模式 | vxeGrid::render | 减去基线(3.2s) | 占自定义渲染总开销 |
|---|---|---|---|
| only-action(仅操作列自定义渲染) | 3,642ms | ~421ms | 2% |
| only-merge(仅合并列自定义渲染) | 3,081ms | ~0ms | 0% |
| only-column(仅普通数据列自定义渲染) | 22,808ms | ~19,587ms | 96% |
结论:renderItem(普通数据列)占自定义渲染开销的 96%。操作列和合并列可忽略。
4c. renderItem 按字段类型耗时(slot JS 执行时间)
在 renderItem 闭包内按 fieldType 统计每次 slot 函数的 JS 执行时间(不含 Vue patch/DOM)。
375 rows × 37 cols 切换后数据:
| 字段类型 | 调用次数 | slot JS 总耗时 | 每次耗时 |
|---|---|---|---|
| SINGLE_SELECTOR | 13,508 | 564.4ms | 0.042ms |
| TEXT_INPUT | 19,648 | 44.4ms | 0.002ms |
| NUMERIC | 3,684 | 10.0ms | 0.003ms |
| DATETIME | 2,456 | 5.6ms | 0.002ms |
| DATE | 1,228 | 2.1ms | 0.002ms |
| EMPLOYEE | 1,228 | 1.4ms | 0.001ms |
| 合计 | 41,752 | 627.9ms | - |
关键发现:
- slot JS 总共 628ms,但 vxeGrid::render 为 25,611ms。差额 ~25,000ms 是 Vue patch + DOM 操作开销
- SINGLE_SELECTOR 每次 0.042ms(TEXT_INPUT 的 21 倍),但绝对值仍很小
- 瓶颈不在 slot 函数执行,而在 slot 返回的 VNode 被 Vue patch 到 DOM 的开销
4d. 各字段类型的渲染组件分析
| 字段类型 | 渲染组件 | DOM 结构 | 复杂度 |
|---|---|---|---|
| SINGLE_SELECTOR | Tag (el-tag + el-popover) / TextTag / LightText |
span + popover/tag | 中等 |
| TEXT_INPUT | h('span', content) |
单个 span | 极轻 |
| NUMERIC | h('span', content) |
单个 span | 极轻 |
| DATETIME | FieldRenderMap[DATETIME] |
格式化 span | 轻量 |
| DATE | FieldRenderMap[DATE] |
格式化 span | 轻量 |
| EMPLOYEE | FieldRenderMap[EMPLOYEE] |
span | 轻量 |
结论:单个组件都不重,但 41,752 次调用 × 每次 3-10 DOM 节点 = 125,000-400,000 DOM 节点的总量是根因。
第四轮总结
渲染耗时分解(375 rows × 37 cols):
vxe-grid 自身 DOM 基线(td + div.vxe-cell): ~3,200ms (13%)
自定义渲染 VNode 的 Vue patch + DOM 开销: ~20,400ms (83%)
├── renderItem(普通数据列): ~19,600ms (96%)
│ ├── SINGLE_SELECTOR 相关 DOM: 占比最大(调用最多、VNode 最复杂)
│ ├── TEXT_INPUT 相关 DOM: 调用最多但 VNode 简单
│ └── 其他类型: 占比小
├── renderAction(操作列): ~420ms (2%)
└── renderMerge(合并列): ~0ms (0%)
slot JS 函数执行: ~628ms (3%)
JS 计算层(useCacheComputed 等): ~56ms (< 1%)
第五轮(2026-03-12):Chrome Performance Trace 分析
通过保存 Chrome Performance 录制的 Trace 文件(Trace-20260312T161653.json.gz),用脚本解析浏览器底层的事件分布。
两个 Long Task
| Long Task | 耗时 | 对应阶段 |
|---|---|---|
| Task 1 | 11,019ms | 主渲染(375行数据到达后的 vxe-grid 渲染) |
| Task 2 | 3,033ms | 过渡渲染(loading 阶段旧 20 行数据的 round#2) |
| Task 3 | 646ms | 其他 |
| Task 4 | 608ms | 其他 |
Task 1(11,019ms)浏览器底层耗时分解
| 类别 | 耗时 | 次数 | 说明 |
|---|---|---|---|
| JS 执行(v8.callFunction) | ~11,019ms | 1 | Vue patch + slot 函数,占据整个 task |
| GC - MinorGC(Scavenger) | ~436ms | 114 次 | 新生代回收,大量短生命周期 VNode/闭包对象 |
| GC - 后台 Scavenge | ~1,962ms | 995 次 | 后台并行 scavenge |
| GC - MajorGC 后台标记 | ~870ms | 174 次 | 老生代标记 |
| UpdateLayoutTree(样式计算) | ~823ms | 4,431 次 | 每批 DOM 插入触发样式重算 |
| Layout(布局计算) | ~146ms | 12 次 | 实际布局计算 |
Task 2(3,033ms)浏览器底层耗时分解
| 类别 | 耗时 | 次数 | 说明 |
|---|---|---|---|
| JS 执行 | ~3,033ms | 1 | 过渡渲染(round#2) |
| GC - MinorGC | ~124ms | 31 次 | |
| UpdateLayoutTree | ~226ms | 1,110 次 | |
| Layout | ~41ms | 4 次 |
Trace 分析关键发现
- GC 压力巨大:主渲染期间 MinorGC 触发 114 次(~436ms on-thread + ~1,962ms 后台),说明 Vue patch 过程中创建了大量短生命周期对象(VNode、h() 返回值、闭包、临时数组等)
- 样式重算频繁:UpdateLayoutTree 被触发 4,431 次共 823ms。vxe-grid 每插入一批 DOM 节点都会触发浏览器样式重算,这是 DOM 数量大的连锁反应
- Layout 不贵:仅 146ms / 12 次,浏览器布局计算本身不是瓶颈
- 过渡渲染浪费 3s:Task 2 的 3,033ms 完全是 useCacheComputed 串联导致的无效渲染(round#2),这是可以消除的
- 总阻塞 ~14s(Task 1 + Task 2),与 console.time 测得的 ~25s 有差异,差异部分是 trace 录制本身的采样误差和后台 GC 时间
已确认的根因(更新)
根因是 375×37 = 13,875 个单元格的自定义渲染产生的 DOM 节点总量过大。
- 每个单元格的 slot 函数本身很快(0.002-0.042ms),不是瓶颈
- 每个单元格的渲染组件也不重(span/tag/轻量组件),不是瓶颈
- 瓶颈是 数量 × 复杂度的乘积效应:41,752 次 slot 调用 × 每次 3-10 DOM 节点 = 巨量 DOM 操作
- vxe-grid 自身基线(纯文本 span)约 3.2s,自定义渲染额外增加 ~20s
已排除的可能原因(更新)
| 可能原因 | 排查结果 |
|---|---|
| slot 渲染函数(renderItem)本身慢 | ❌ 排除。0.002-0.042ms/次,628ms 总计,仅占 2.5% |
| 某个特定字段类型的组件特别重 | ❌ 排除。所有类型都是轻量 span/tag,无重量级组件 |
:key="columns.length" 导致重建 |
❌ 排除。key=37 前后不变,未触发 onUnmounted |
| ResizeObserver recalculate 触发二次渲染 | ❌ 排除。recalculate 日志未出现 |
| 多轮重复渲染 | ❌ 排除。只有 1 轮有效渲染(round#3),无多轮 |
| stableStringify / useMergeCells 等 JS 计算 | ❌ 排除。总计 ~56ms,可忽略 |
| renderAction(操作列)开销大 | ❌ 排除。仅 ~421ms(2%) |
| renderMerge(合并列)开销大 | ❌ 排除。~0ms |
useCacheComputed 所有调用点
| # | 实例 | 文件 | watcher 内容 | 分页切换时触发 | 实测耗时 |
|---|---|---|---|---|---|
| 1 | #1 | Columns.ts:298 | schema.children, Maps, pageConfig | 不触发 | - |
| 2 | #2 | ListVxePreview.vue:184 | [] 空数组 |
不触发 | - |
| 3 | #3 | ListVxePreview.vue:263 | data, schema, columns, loading | 触发 | stringify 31ms |
| 4 | #4 | VxeTable.vue:113 | mergeCells, data, columns, loading | 触发 | stringify 12ms |
优化方向(基于四轮实测数据)
| 优先级 | 方向 | 方案 | 预期收益 |
|---|---|---|---|
| P0 | 减少 DOM 节点总量 | 开启虚拟滚动 virtualY(仅渲染可视区域行) | 从 375 行降至 ~20 行可视,理论降至 < 2s |
| P0 | 简化 SINGLE_SELECTOR 渲染 | Tag/el-tag/el-popover 替换为纯 span 显示(列表模式不需要交互组件) | 减少最大类型的 DOM 节点数 |
| P1 | 消除过渡渲染 | 两个 useCacheComputed 串联导致 round#2 浪费 ~631ms | 省 ~631ms |
| P1 | 减少 slot 调用次数 | 41,752 次调用 / 13,875 单元格 = 3x,排查 vxe-grid 内部重复调用原因 | 可能省 1/3 渲染时间 |
| P2 | v-bind="gridProps" 拆分 | 拆分为独立 props,避免全量对象替换 | 减少内部更新次数 |
| P3 | stableStringify 移除 | 改用 Vue 原生响应式追踪 | 省 ~43ms |
关键文件清单
| 文件 | 作用 |
|---|---|
packages/form-designer/src/config/widget/list-vxe/composables/useCacheComputed.ts |
缓存计算核心 |
packages/form-designer/src/config/widget/list-vxe/composables/Columns.ts |
列计算逻辑 |
packages/form-designer/src/config/widget/list-vxe/composables/fetch.ts |
数据获取 |
packages/form-designer/src/config/widget/list-vxe/preview/ListVxePreview.vue |
列表预览组件 |
packages/form-designer/src/config/widget/list-vxe/preview/core-table/VxeTable.vue |
VxeTable 封装 |
packages/form-designer/src/config/widget/list-vxe/preview/core-table/useMergeCells.ts |
合并单元格计算 |
packages/form-designer/src/config/widget/list-vxe/render/RenderItem.tsx |
单元格渲染 |
packages/form-designer/src/config/widget/list-vxe/render/render.tsx |
渲染器入口 |
packages/form-designer/src/config/widget/list-vxe/render/RenderAction.tsx |
操作列渲染 |
packages/form-designer/src/config/widget/list-vxe/render/RenderMerge.tsx |
合并列渲染 |