/table-test 表格性能排查报告(终版)

日期:2026-10-16 对象:web/src/views/table-test/index.vue + @g3soft/ui G3Table(底座 stk-table-vue 1.2.7) 状态:根因已定位,未改代码
本终版由两份独立排查合并去重而成:①真实页面实测(测试进程内拦截 s_power 响应补授权,headed + 真实 GPU,含冻结克隆对照);②复刻探针页实测(headless,含 virtual 开/关判定与 errorCount 开关对比)。以①的数据为基准,②补充归因实验。两份的探针脚本均可复跑(见附录 B)。

摘要(TL;DR)

  1. 滚动卡的唯一根因:数据量 > 100 行时纵向虚拟滚动开启,每滚一步触发整个表格组件重渲染(约 550 个可见格的 props 重算 + style 重写 + 行窗口 DOM 进出)。冻结克隆实验证明:同一份 DOM、同一套 CSS,不触发重渲染就是满帧 60fps——CSS 不是根因,是重渲染发生时放大每帧成本的因素。
  2. 点击卡的主因:首次点击使 editingActive 翻真 → displayColumns 23 个列对象全量重建 → 底座表头/几何/样式缓存全部失效 → 全表重排重绘(实测 298~394ms、966~1008 次 DOM 变更);换行点击 94~250ms(行级编辑态整行挂约 17 个编辑器);同行换列仅 0.7ms。
  3. 点击后滚动进一步恶化(纵向 15~18 FPS → 8~11 FPS):常驻 customCell 的列从 10 涨到 17;且底座选区 watcher 依赖滚动窗口索引,每次滚动都全量遍历可见格更新选区 DOM。
  4. 横向滚动中位 20~26 FPS、100 行时恶化到 7.8 FPS:updateFixedShadow 每次横滚都无条件写入新数组 → 固定列 class map 失效 → 全部单元格重渲染(与总行数/是否虚拟无关,与可见格数成正比)。
  5. 页面自身的 errorCount 每次提交全量重跑「行数 × 22 列」校验,严格线性(7μs/格):1000 行 +31~69ms、5000 行 +215~434ms;失焦端到端 286ms@1000 / 465ms@5000,点保存 318ms / 612ms。
  6. 已排除:GPU/软件渲染无差别、行数不是直接因素、列宽拖拽零开销、关闭虚拟滚动不可取(23000 DOM、6.1s 挂载)。

一、性能基线(实测)

基准口径:视口 1600×900,表格容器约 1308×656,rowHeight=28,默认 1000 行;帧率为滚动 3~5 秒窗口的中位数;真实页 + 真实 GPU(GTX 1050,帧率计数器口径)。

场景帧率 / 耗时最差帧说明
纵向滚动 · 1000 行15~17.4 FPS(≈58~67ms/帧)~600ms页面默认状态
纵向滚动 · 5000 行15~17 FPS~600ms与总行数无关
纵向滚动 · 点开格子后(编辑态)8~11 FPS—customCell 10→17 列 + 选区 watcher(见 RC5/RC7)
纵向滚动 · 10 / 100 行54~60 FPS—≤100 行门控自动关闭虚拟滚动
纵向滚动 · 克隆的静态 DOM60 FPS—同一 DOM 同一 CSS,仅不挂 Vue
横向滚动 · 1000 行中位 20~26 FPS120~4800msfixed-col-shadow 开启
横向滚动 · 100 行7.8 FPS—2300 个 td 全量在 DOM,重渲染成本随之放大
首次点击某格298~394ms—966~1008 次 DOM 变更
换行点击(同列 / 换列)94~250ms—约 70 次 DOM 变更(整行编辑器重挂)
同一行换列点击0.7ms0 次变更仅编辑器重挂载一格
编辑提交(失焦)端到端286ms@1000 / 465ms@5000—含 errorCount 全表重算
点「保存明细」318ms@1000 / 612ms@5000—同上
纯打字(编辑器内输入)无明显长任务—草稿机制正常工作

errorCount 线性标定(另一处页面级问题)

数据量errorCount 计算耗时换算
10 行0.9~2.8ms约 7μs/格(每格跑一次 validate + 一次 checkPrecision),与行数严格线性
100 行14~16ms
1000 行31~69ms
5000 行215~434ms

二、根因链(去重合并,按影响排序)

RC1 · 虚拟滚动开启后,每滚一步全表重渲染 (滚动卡的根子)

机制:triggerRef(virtualScroll) 触发的是单个 StkTable 组件的更新 → 表头 23 个 th + 可见约 25~31 行 × 23 列 ≈ 550~713 个格子的 props 重算、style 重写、keyed diff,外加 2 行 DOM 进出与占位高度变化。每次 window 变化都全量重做,无任何按行/按列的跳过。
证据:①冻结克隆(同一 DOM 滚动 60fps vs live 15fps);②virtual 开/关、10/100 行、5000 行帧率一致;③CDP profile 中 67% 时间在浏览器端 (program)(样式重算/布局/绘制),Vue JS 仅 ~7%——JS 便宜,贵在每帧 713 格的 style/layout/paint;④LoAF 直接指向底座的 rAF 回调。
位置:stk-table-vue/src/StkTable/StkTable.vue:1927(onTableScroll)、useVirtualScroll.ts:140-148(窗口变化即 triggerRef)。

RC2 · 首次点击触发「列重建级联」,全表重排重绘 (点击卡的主因)

机制:mousedown → 选区变化 → editingActive 翻真 → displayColumns 23 个列对象全部重建 → 底座 props.columns 引用变化 → handleDealColumns + updateMaxRowSpan + initVirtualScrollX + updateFixedShadow → tableHeaders 更换 → cellStyleMap 重建 → 全表重渲染 + 编辑器挂载 + focus。整条链没有任何 memo/短路。
证据:首次点击 298~394ms / 966~1008 次 DOM 变更(另一复刻页实测:137ms + 389ms 两个 longtask + 一个 416ms 帧);点击的 CPU profile 同样显示主要成本在浏览器端(364ms (program))。后续点击不重建列(editingActive 已为真),成本降至 94~250ms。
位置:G3Table.vue:584(activateEditing)、G3Table.vue:923(displayColumns)、StkTable.vue:1240(columns watch)。

RC3 · 行级编辑态:一行点亮,整行约 17 个编辑器全部挂载

机制:editingActive 为真时该行每个可编辑列都挂编辑器(date 除外,已按格挂载)。一次换行点击 = 旧行 17 个卸载 + 新行 17 个挂载 + 状态类重写。
证据:编辑器根节点数 24 → 41~42(一次点击净增约 17 个);换行点击 94~187ms / 约 70 次 DOM 变更。对照组:同一行换列只重挂一格,0.7ms / 0 次 DOM 变更。
位置:utils/column.ts:121-134(注入判据)、utils/editable.ts:1177(createEditableCell 按行分发)。

RC4 · 页面 errorCount:每次提交全表重跑校验

机制:errorCount 是 computed,依赖整个深层响应式 rows,每次失焦/回车/checkbox 提交都重跑「行数 × 22 列」,每格跑一次 validate + 一次 checkPrecision。选 20000 行时按线性外推约 +1.4s/次。
证据:线性标定见上表;复刻页 A/B:Enter 提交 139ms vs 79ms(1000 行)、278ms vs 85ms(5000 行)。
位置:web/src/views/table-test/index.vue:615-625。

RC5 · 每格渲染的重复劳动:校验重算 + 每格新建一个 computed

机制:customCell 每次渲染都重新求校验态(业务 validate + checkPrecision + 日期校验 + 数字格式化),且每格调用 useNamespace('table') 会 新建一个 computed 并 inject。滚动时每帧几百格都在做这两件事。
证据:A/B 去掉全部 editor 后纵向 18.2 → 25.2 FPS(+38%)、横向 32.2 → 43.3 FPS(+34%);profile 中 createReactiveObject/refreshComputed 在点击与滚动中均可见。
位置:utils/editable.ts:369-454(displayValue)、_composables/useNamespace.ts:41。

RC6 · 横向滚动:固定列 class map 每帧失效导致全量重渲染

机制:updateFixedShadow 在每次横滚中被调用,并无条件把新数组赋给 fixedCols / fixedShadowCols / BorderLeftCols 三个 shallowRef(即使内容毫无变化)→ fixedColClassMap computed 失效 → 所有 th/td 重渲染。与是否虚拟滚动无关,只与可见格数成正比。
证据:100 行(2300 格全量在 DOM、纵向因不换窗而 58.8 FPS)横滚却只有 7.8 FPS;1000 行(约 552 格)横滚中位 20~26 FPS。格数比 4.2×、帧时间比约 2.8×,方向吻合。另一复刻页在无选区状态下实测横滚接近满帧(p50 16.7ms)——差异归于选区/编辑态与页面外壳,以真实页数据为准。
位置:stk-table-vue/src/StkTable/features/useFixedCol.ts:157-164(无条件写新数组)、StkTable.vue:1945-1952(横滚即调用)。

RC7 · 点击之后滚动更卡:常驻编辑列增多 + 选区 watcher 每帧全量跑

机制 a:editingActive 翻真后 customCell 常驻列从 10(select/checkbox/validate/precision)涨到 17(text/number/date 加入),每格每帧走 displayValue(RC5)。
机制 b:底座选区 watcher 依赖 vs.startIndex/endIndex → 滚动换窗即触发 updateSelectionDOM:清旧选区 + 逐行 querySelectorAll('td[data-col-key]') 遍历全部可见格、逐格生成 cellKey。选区点击后常驻(Esc、点表格外都不清除),此后每次滚动都付这笔钱。
证据:编辑态纵向 8~11 FPS(vs 未点击 15~18);复刻页实测点击后滚动 44.1ms → 74.8ms/帧。
位置:useAreaSelection.ts:319-342(watcher 依赖滚动窗口)、useAreaSelection.ts:217(updateSelectionDOM)。

三、已排除的嫌疑(避免重复踩坑)

四、修改方向(合并去重,按优先级)

P0 · 页面级 · 零风险 errorCount 改为按需计算

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

做法:从 computed 改为保存时才算——saveEdit() 里与 findFirstError() 合并成一次全表遍历(汇总数 + 首个错误一次拿到);工具栏徽章改为保存后刷新,或退而求其次只统计可见行 + 防抖。

收益:失焦 286→~90ms@1000、465→~90ms@5000;保存 318/612ms 同步下降。风险:无(徽标语义不变)。

P0 · 组件库 阻断首次点击的「列重建级联」

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

顺序(从易到难,每步可独立上线):
1. 先用 Object.is 逐层比对确认到底哪一环翻转了列引用(防止改错地方);
2. 两态记忆化:按 editingActive 的真/假各缓存一份列数组,行与行之间切换不重建(现已在行间切换不翻转,缺的只是首击那次);
3. 最终让 customCell 注入不再依赖 editingActive:由 cell 内部 ctx.isEditing(row) 分发(函数本就接收 row),列引用从此稳定,点击不再触碰底座几何链。

收益:首次点击 298~394ms → 目标 ~50ms 以内。风险:中(需回归冻结列/列宽/编辑激活的 e2e)。

P1 · 根子 · 工作量最大 虚拟滚动行级化,消灭每帧全表重渲染

位置:stk-table-vue(上游库)或 G3Table 的 tbody 组织方式。

做法:把 tbody 行拆成独立子组件(每行一个渲染作用域),行数据不变的行跳过 patch;或对可见窗口做更细粒度的 diff 复用。这是 RC1 的正解,也是让纵向从 15~18 FPS 回到 60 FPS 量级的唯一路径。

收益:滚动卡顿整体解决(横向同步受益于 RC6 修复)。风险:高(触及底座渲染核心,需完整 e2e 回归:虚拟滚动、冻结列、合并单元格、树形、区域选择)。

P1 · 组件库 updateSelectionDOM 不再随滚动窗口全量跑

位置:useAreaSelection.ts:319-342。

做法:watcher 去掉对 startIndex/endIndex 的依赖(滚动换窗时按需增量更新选区属性即可);选区为空时早退;把「逐格生成 cellKey 查 Set」换成按 selectedCellKeys 直接定位 td[data-col-key] 批量 setAttribute。

收益:点击后滚动 8~11 FPS → 15~18 FPS 档。风险:低-中(需回归拖选/Shift 扩选/复制)。

P1 · 组件库 校验态按值缓存 + 消灭每格一个 computed

位置:utils/editable.ts:369-454(displayValue)、_composables/useNamespace.ts:41。

做法:①校验结果按 (dataIndex, value) 做值键缓存,注意跨列校验(如「目的港≠起运港」)的失效条件——按列声明依赖或按 (row 版本号 + value) 失效;②useNamespace 的 b/e/is 是纯字符串拼接,下沉为模块级单例,别在每格渲染里新建 computed。

收益:滚动每帧几百次 validate/checkPrecision 大幅收敛(参照 A/B:去 editor 后纵向 +38% 中的一部分)。风险:中(缓存失效条件是主要出错点,需单测覆盖跨列校验)。

P1 · 组件库 行内编辑从「整行挂载」改为「只挂当前格」

位置:utils/column.ts:121-134 + utils/editable.ts:1177。

做法:date 已是「只给 focusCol 挂编辑器」的模式,把该模式推广到 text/number/select;非当前格走 displayValue(一个 span)。

收益:换行点击 94~250ms → 接近同行换列的 0.7ms 量级;编辑态滚动每帧少几百个函数式组件。风险:中(键盘连续录入 / Tab 跳格 / Enter 下移链路需 e2e 兜底)。

P1 · 组件库 横滚干净 A/B:定位 RC6 与 sticky 的真实占比

位置:style/_overrides.scss:460(恒常 sticky 补丁)+ useFixedCol.ts:157-164。

做法:A/B 只切 position、保留 z-index 与观感(避免「一起改混味道」);同时验证 RC6 机制——固定列 class map 无变化时跳过写入(先算字符串、有变化再赋值)。两个假设不互斥,用同一组实验一起验。

收益:横向 20~26 FPS → 60 FPS 量级;顺带消除 100 行区间的横滚塌陷。风险:低(观感回归为主)。

P2 · 顺手 will-change 限定在 transform 滚动模式

位置:G3Table.vue:1932-1941。无条件给约 28000px 高的 tbody 挂合成层;库自己仅在 .exp-scroll-y 下才加(stk-table-vue style.less:392-396)。与该条件对齐即可。实测单独去除改善有限(41→38ms),属低成本顺手项。

P2 · 记录 100 行区间记为已知边界

MIN_VIRTUAL_ROW_COUNT = 100 的门控是合理设计(小表示全量渲染更稳),不建议为性能调高——那只是把问题藏进「DOM 全量」。待 P1 修复落地后,该区间的横滚塌陷会随 RC6 一起消失,届时可重新评估阈值。

附录 A · 关键代码位置索引

文件行内容
g3soft-libs/packages/ui/src/table/G3Table.vue251纵向虚拟门控 MIN_VIRTUAL_ROW_COUNT = 100
584activateEditing / editingActive 翻转
923displayColumns 全量重建(RC2 入口)
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:展示态每格跑校验(RC5)
1177createEditableCell:行级编辑器分发(RC3)
g3soft-libs/packages/ui/src/_composables/useNamespace.ts41每格渲染新建一个 computed(RC5)
g3soft-libs/packages/ui/src/table/style/_overrides.scss460fixed-cell 恒常 sticky 补丁(A/B 对象)
stk-table-vue/src/StkTable/StkTable.vue1240columns 变化 → 全量重算链(RC2)
1927onTableScroll:rAF 节流的滚动入口(RC1)
1945横滚分支调用 updateFixedShadow(RC6)
stk-table-vue/src/StkTable/useFixedCol.ts157每次横滚无条件写新数组 → class map 失效(RC6)
stk-table-vue/src/StkTable/features/useAreaSelection.ts319选区 watcher 依赖 startIndex/endIndex(RC7b)
217updateSelectionDOM 全可见格遍历(RC7b)
web/src/views/table-test/index.vue615errorCount computed(RC4)

附录 B · 探针资产与复跑

两份排查的探针脚本均为只读(不改业务代码、不请求业务接口、可随时删):

权限说明:demo_zhangwei 访问 /table-test 会 403(该账号只授了 menu.approval% 与 action.v_b_othercompany.%)。sql/20_registration/13_table_test_menu.sql 只建菜单、不建权限点——要普通账号可进,需执行其末尾「可选:给普通账号授权」段;或直接用 g3soft。

附录 C · 口径与已知局限