/table-test 表格性能排查报告

日期:2026-10-16 对象:web/src/views/table-test/index.vue + @g3soft/ui G3Table(底座 stk-table-vue 1.2.7) 状态:已定位根因,未改代码

摘要(TL;DR)

  1. 滚动卡顿的根因:数据量 > 100 行时纵向虚拟滚动开启,每滚一步都触发整个表格组件重渲染(可见约 31 行 × 23 列 ≈ 713 个单元格重算 + 行窗口 DOM 进出),CPU profile 显示 67% 时间消耗在浏览器端样式重算/布局/绘制。同为 1000 行关掉虚拟滚动(全量渲染 23000 个 td)反而满帧 60fps —— 卡的是"每帧重渲染",不是表格大。
  2. 点击卡顿的根因:首次点击使 editingActive 翻真,触发全量列重建(23 个新列对象 → 列宽/样式缓存重建 → 全表重排重绘),实测主线程阻塞约 400~550ms;后续点击约 130~200ms。
  3. 点击后滚动会更卡(44ms → 75ms/帧):编辑列全部注入 customCell 每格跑校验;且底座区域选择的 watcher 依赖滚动窗口索引,每次滚动都全量遍历可见格更新选区 DOM。
  4. 页面自身还有一处:errorCount computed 每次提交都全量重跑「行数 × 22 列」校验,1000 行多付 ~60ms、5000 行多付 ~190ms,20000 行约 +0.8s/次。
  5. 横向滚动基本正常(p50 16.7ms),问题集中在纵向滚动与点击编辑。

一、测试方法

数值口径说明:headless Chromium 为软件渲染(无 GPU 合成),绝对帧时间比实机差;但相对倍数、瓶颈归属(浏览器端 vs JS)、longtask 分布在真实环境同样成立。文中所有结论均经「开/关某一因素」的对照实验确认,非单纯推断。

二、症状一:滚动 FPS 特别低

2.1 实测数据(1000 行,视口 1440×900,容器高约 866px)

场景平均帧p50p95卡顿帧(>32ms)
纵向滚动,virtual on(页面默认 1000 行)34~75ms(13~29 FPS)33~100ms83~167ms30~41 / 约 60 帧
纵向滚动,点击过单元格后(选区常驻)74.8ms(13 FPS)99.9ms166.7ms19 / 35
横向滚动(fixed-col-shadow 开)19.9ms16.7ms33.4ms22
同样 1000 行,virtual off(全量 23000 td,滚动零重渲染)28ms16.7ms116.6ms23
100 行(门控自动关闭虚拟)16.7ms(60 FPS)16.7ms16.7ms0
裸 div 5000 行(浏览器基线)16.7ms(60 FPS)16.7ms16.8ms0

2.2 根因链

  1. G3Table 的纵向虚拟门控为「行数 > 100 开启」(g3soft-libs/packages/ui/src/table/G3Table.vue:251),页面默认 1000 行 → 开启。
  2. 滚轮/滚动条每动一次 → onTableScroll(rAF 节流)→ updateVirtualScrollY 换窗 → triggerRef(virtualScroll) → 整个 StkTable 组件重渲染:表头 23 个 th + 可见 31 行 × 23 列 ≈ 713 个单元格的 props 重算与 diff,外加 2 行 DOM 进出。
  3. CPU profile(滚动 2.5s):67% 时间在浏览器端 (program)(样式重算/布局/绘制),Vue JS 仅 ~7%,GC 2.9%,setProperty(Vue 写 style)4%。即贵在每帧 713 格的 style/layout/paint。
  4. 浏览器端成本被以下 CSS 放大:
    • .g3-table__inner .stk-tbody-main { will-change: transform }(G3Table.vue:1936)——无条件给约 28000px 高的 tbody 挂合成层;库自己只在 transform 滚动模式(.exp-scroll-y)下才加(stk-table-vue/src/StkTable/style.less:392),本页走原生滚动,这层纯属白付(实测去掉仅小幅改善,属次要因素)。
    • 4 个冻结列 × 31 行 ≈ 124 个 position: sticky 格(补丁让所有 fixed-cell 恒常 sticky,style/_overrides.scss:460)。
    • 29 条 td:has(...) 规则(编辑器/校验/只读态反查)+ 库用背景渐变画格线。
  5. 判定性对照:同样 1000 行显式 virtual=false(一次渲染 23000 个 td)→ 滚动 p50 回到 16.7ms 满帧。证明卡的是「虚拟滚动开启后的每帧重渲染」,而非 DOM 总量。

2.3 为什么点击后滚动更卡(44ms → 75ms/帧)

  1. editingActive 翻真后,注入 customCell 的列从 10 个(select/checkbox/validate/precision 常驻)涨到 17 个(text/number/date 全部加入,utils/column.ts:121-134),每格每帧走 displayValue:跑业务 validate + checkPrecision + 日期校验 + 数字格式化(utils/editable.ts:369-454)。
  2. 底座区域选择的 watcher(stk-table-vue/src/StkTable/features/useAreaSelection.ts:319-342)依赖 vs.startIndex/endIndex → 滚动换窗即触发 updateSelectionDOM:先 querySelectorAll 清旧选区,再逐行 querySelectorAll('td[data-col-key]') 遍历全部可见格、逐格生成 cellKey 并查 Set。选区在点击后常驻(Esc、点表格外都不清除,实测点击后选区数仍为 1),此后每次滚动都付这笔钱。

三、症状二:点击单元格卡顿

3.1 实测 longtask(主线程被阻塞时长)

操作longtask 序列随后最大帧估算阻塞
首次点击某可编辑格137ms → 389ms416.7ms~400~550ms
点另一行同列92ms → 136ms166.7ms~230~300ms
行内换到另一列130ms → 121ms133.3ms~250ms
点只读列(订舱号)97ms → 198ms216.6ms~300ms

编辑器聚焦本身很快(mousedown 后 ~0.3ms 即聚焦),卡的是点击引发的重渲染/重排链条。

3.2 根因链

  1. mousedown → 底座选区变化 → handleAreaSelectionChange → activateEditing → editingActive 翻真(G3Table.vue:584-609)。
  2. displayColumns 全量重建:23 个列全部 map 成新对象(G3Table.vue:923-1004)。
  3. StkTable props.columns 引用变化 → watch → handleDealColumns + updateMaxRowSpan → nextTick → initVirtualScrollX + updateFixedShadow + updateCustomScrollbar(StkTable.vue:1240-1252)。
  4. tableHeaders 更换 → cellStyleMap 重建 → 全表重渲染 + 编辑器挂载 + focus()。CPU profile:主要成本仍在浏览器端(364ms (program),即全表 style/layout/paint);focus() 本身占 40~57ms。
  5. 后续点击不重建列(editingActive 已为真),只剩行编辑态切换 + 选区 DOM 更新 + setCurrentRow,故成本降至 ~130~200ms。

四、症状三(顺带发现):页面自身的 errorCount 放大编辑提交卡顿

views/table-test/index.vue:615-625 的 errorCount 是 computed,依赖整个深层响应式 rows,每次提交(Enter / 失焦 / checkbox)都全量重跑「行数 × 22 列」校验(每格还要跑 validate + checkPrecision)。实测 Enter 提交耗时:

数据量errorCount 开errorCount 关差值
1000 行139ms79ms+60ms
5000 行278ms85ms+193ms

按线性外推,选 20000 行时每次提交约多付 ~0.8s。这是页面级问题,与组件库无关;工具栏徽章只在打开页面/保存时有意义,不必随每次编辑重算。

五、修改方向(按性价比排序,本次未实施)

P1 · 根子 虚拟滚动开启时避免整表重渲染

位置:stk-table-vue(node_modules,属上游库)或 G3Table.vue 的 tbody 渲染组织方式。

问题:triggerRef(virtualScroll) 一触发,整个 StkTable 单组件重渲染,713 个单元格 props 全量重算与 diff。

方向:把 tbody 行拆成独立子组件(每行一个渲染作用域),行数据不变时跳过该行 patch;或对可见窗口做更细的 keyed diff 复用。virtual off 满帧已证明 DOM 量不是问题,问题是每帧全量重算。

P2 去掉无条件的 will-change: transform

位置:G3Table.vue:1932-1941(SCSS 块,非 scoped)。

问题:原生滚动模式下也给 28000px 高的 tbody 常驻合成层;库自己仅在 .exp-scroll-y 下才加(style.less:392-396)。

方向:与该条件对齐,仅在 transform 滚动模式输出此规则;或直接删除(transform 模式由库自己加)。实测单独去除改善有限(41→38ms),属低成本顺手项。

P2 updateSelectionDOM 不应随滚动窗口全量跑

位置:stk-table-vue/src/StkTable/features/useAreaSelection.ts:319-342。

问题:watcher 依赖 startIndex/endIndex,滚动换窗即对全部可见格做 DOM 遍历 + cellKey 生成;选区常驻导致每次滚动都付。

方向:选区为空时早退;把「逐格生成 cellKey 查 Set」改为按 selectedCellKeys 直接定位 td[data-col-key] 批量 setAttribute;或只在选区真正变化时重算,滚动换窗时按需增量更新。

P2 点击时不做全量列重建

位置:G3Table.vue:923(displayColumns computed 依赖 editingActive)。

问题:首次点击 editingActive 翻真 → 23 个列对象全部重建 → 列宽/样式缓存重建 → 全表重排重绘(~400ms)。

方向:编辑态从「全表列级」下沉为「行级」——只给目标行/目标列注入编辑器(customCell 内部已有 ctx.isEditing(row) 判断,具备按行分发的基礎);或缓存列对象、仅对受影响的列生成新引用,避免 tableHeaders/cellStyleMap 整体失效。

P1 · 零风险 errorCount 从 computed 改为按需计算

位置:web/src/views/table-test/index.vue:615-625。

问题:每次提交全量重跑 行数×22 校验,1000 行 +60ms、5000 行 +193ms、20000 行约 +0.8s。

方向:改为在 saveEdit() 里按需算一次(保存时本就要全表校验,可与 findFirstError 合并成一次遍历);或只统计可见行 + 防抖。徽标语义不变。

P2 评估每格校验的缓存

位置:utils/editable.ts:369-454(displayValue → resolveEditorStatus)。

问题:滚动时每格每帧重跑 validate + checkPrecision + 日期校验 + 格式化;点击后常驻 customCell 从 10 列涨到 17 列,成本随之上涨。

方向:校验结果是行数据的派生值,可按 (row, dataIndex, value) 做 WeakMap 缓存,值不变则复用;useNamespace 每格渲染都新建 computed(_composables/useNamespace.ts:41),可下沉为模块级单例。

P3 · 设计取舍 虚拟滚动门控阈值

位置:G3Table.vue:251(MIN_VIRTUAL_ROW_COUNT = 100)。

说明:阈值本身合理(小表示全量渲染更稳),不建议为性能调高——那只是把问题藏到「DOM 全量」里,万行级内存与首渲染会更差。应优先修 P1,让虚拟滚动本身变快。

附录:关键代码位置索引

文件行内容
g3soft-libs/packages/ui/src/table/G3Table.vue251纵向虚拟门控 MIN_VIRTUAL_ROW_COUNT = 100
584activateEditing / editingActive 翻转
923displayColumns 全量重建(点击卡顿入口)
1207handleAreaSelectionChange(点击 → 进编辑链路)
1932tbody 无条件 will-change: transform
g3soft-libs/packages/ui/src/table/utils/column.ts121customCell 注入判据(editingActive 后 10 → 17 列)
g3soft-libs/packages/ui/src/table/utils/editable.ts369displayValue:展示态每格跑校验
g3soft-libs/packages/ui/src/table/style/_overrides.scss460fixed-cell 恒常 sticky 补丁
stk-table-vue/src/StkTable/StkTable.vue1240columns 变化 → 全量重算链
stk-table-vue/src/StkTable/StkTable.vue1927onTableScroll(rAF 节流的滚动入口)
stk-table-vue/src/StkTable/features/useAreaSelection.ts319选区 watcher 依赖 startIndex/endIndex
stk-table-vue/src/StkTable/features/useAreaSelection.ts217updateSelectionDOM 全可见格遍历
web/src/views/table-test/index.vue615errorCount computed(页面级提交卡顿)