Files
wukuang/perf/list-vxe-performance-analysis.md
T
2026-05-23 14:05:22 +08:00

306 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# list-vxe 500条数据渲染卡顿性能分析
## 背景
PC端列表在将分页从20条切换到500条时,渲染卡顿约8-10秒。后端API响应仅660ms,36列 + 373条数据量不大,未开启虚拟滚动也不应如此卡顿。
## 实测数据
### 第一轮(2026-03-12):粗粒度定位
测试条件:374 rows × 37 columns = 13,838 cells,分页从20切换到500。
```text
[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 函数,测量单元格渲染函数的调用次数与耗时。
```text
[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` 追踪每轮首次调用的触发栈,并按轮次分别统计。
```text
[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. **只有 1 轮有效渲染**round#3),不是多轮重复渲染。round#2 是 loading 阶段的过渡渲染(仍为旧 20 行数据)
2. **slot 函数本身很快**:平均 0.013ms/次,528.6ms 总计,仅占 wallTime 的 1.4%
3. **vxe-grid 内部 + DOM 操作占 98.6%**36,563ms wallTime - 528.6ms slotJs ≈ **36,034ms**
4. **每个单元格 slot 被调用 ~2.84 次**39,375 / (375×37) = 2.84。部分是 header cell 渲染,部分可能是 vxe-grid 内部测量
5. **`:key` 未触发重建**key=37 不变
6. **ResizeObserver recalculate 未触发**:不是重复渲染的原因
7. **两个 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
## 数据流:分页切换时的完整触发链
```text
用户切换分页 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 复杂度贡献了 ~20s86%)的渲染开销。**
#### 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** | - |
**关键发现:**
1. **slot JS 总共 628ms,但 vxeGrid::render 为 25,611ms**。差额 ~25,000ms 是 Vue patch + DOM 操作开销
2. **SINGLE_SELECTOR 每次 0.042ms**TEXT_INPUT 的 21 倍),但绝对值仍很小
3. **瓶颈不在 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 节点的总量是根因。**
### 第四轮总结
```text
渲染耗时分解(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 111,019ms)浏览器底层耗时分解
| 类别 | 耗时 | 次数 | 说明 |
| --- | --- | --- | --- |
| JS 执行(v8.callFunction | ~11,019ms | 1 | Vue patch + slot 函数,占据整个 task |
| GC - MinorGCScavenger | ~436ms | 114 次 | 新生代回收,大量短生命周期 VNode/闭包对象 |
| GC - 后台 Scavenge | ~1,962ms | 995 次 | 后台并行 scavenge |
| GC - MajorGC 后台标记 | ~870ms | 174 次 | 老生代标记 |
| UpdateLayoutTree(样式计算) | **~823ms** | **4,431 次** | 每批 DOM 插入触发样式重算 |
| Layout(布局计算) | ~146ms | 12 次 | 实际布局计算 |
#### Task 23,033ms)浏览器底层耗时分解
| 类别 | 耗时 | 次数 | 说明 |
| --- | --- | --- | --- |
| JS 执行 | ~3,033ms | 1 | 过渡渲染(round#2 |
| GC - MinorGC | ~124ms | 31 次 | |
| UpdateLayoutTree | ~226ms | 1,110 次 | |
| Layout | ~41ms | 4 次 | |
#### Trace 分析关键发现
1. **GC 压力巨大**:主渲染期间 MinorGC 触发 114 次(~436ms on-thread + ~1,962ms 后台),说明 Vue patch 过程中创建了大量短生命周期对象(VNode、h() 返回值、闭包、临时数组等)
2. **样式重算频繁**UpdateLayoutTree 被触发 4,431 次共 823ms。vxe-grid 每插入一批 DOM 节点都会触发浏览器样式重算,这是 DOM 数量大的连锁反应
3. **Layout 不贵**:仅 146ms / 12 次,浏览器布局计算本身不是瓶颈
4. **过渡渲染浪费 3s**Task 2 的 3,033ms 完全是 useCacheComputed 串联导致的无效渲染(round#2),这是可以消除的
5. **总阻塞 ~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` | 合并列渲染 |