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

16 KiB
Raw Permalink Blame History

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. 只有 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。

数据流:分页切换时的完整触发链

用户切换分页 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.042msTEXT_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 节点的总量是根因。

第四轮总结

渲染耗时分解(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. 过渡渲染浪费 3sTask 2 的 3,033ms 完全是 useCacheComputed 串联导致的无效渲染(round#2),这是可以消除的
  5. 总阻塞 ~14sTask 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(操作列)开销大 排除。仅 ~421ms2%
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 合并列渲染