# 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 单元格 → 每个单元格包含 `` + `` + slot 内容 ≈ 3-5 个 DOM 节点 总计约 **42,000-70,000 个 DOM 节点**的创建、插入、样式计算、布局。 额外开销:两个 useCacheComputed 串联导致 1 次过渡渲染(round#2,旧 20 行数据),浪费 ~631ms。 ### 第四轮(2026-03-12):自定义渲染 vs 纯文本对比 + 按类型拆解 #### 4a. 纯文本 vs 自定义渲染对比 将 `getCellRender` 中所有 slot 替换为 `{原始值}`,对比渲染耗时。 | 模式 | vxeGrid::render | 减去基线 | 占比 | | --- | --- | --- | --- | | 纯文本 `` | **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** | - | **关键发现:** 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 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 分析关键发现 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` | 合并列渲染 |