/table-test 表格性能报告与修改方向

日期:2026-10-16 状态:已定位根因,未改动任何业务代码
对象:g3soft-erp/web/src/views/table-test/index.vue 组件:g3soft-libs/packages/ui/src/table/G3Table.vue 底座:stk-table-vue 1.2.7

摘要(TL;DR)

  1. 不是 CSS/绘制慢,是每帧重渲染慢。决定性证据:把表格 DOM 原样 cloneNode(true) 出来放进同样尺寸的容器里滚动 → 60 FPS 满帧;同一份 DOM 由 Vue 管着滚 → 11~18 FPS。CSS(sticky / 渐变格线 / box-shadow / :has())全被这一条排除。
  2. 滚动卡:虚拟滚动窗口每帧变化 → 整个表格组件重渲染 → 约 550 个可见单元格的 props / style 重新写入 → 浏览器每帧重算样式 + 重排 + 重绘,单帧 60~90ms。代价与总行数无关(1000 行与 5000 行帧率相同),只与渲染出的单元格数有关。
  3. 点击卡:页面加载后第一次点任意格,全表布尔量 editingActive 由 false 翻 true → displayColumns 整体重建为 22 个新列对象 → 底座 watch 触发 handleDealColumns() + initVirtualScrollX() + updateFixedShadow() → 实测 394ms / 966 次 DOM 变更。
  4. 换行点击同样是 100~190ms(约 70 次 DOM 变更),因为行级编辑态会让整行可编辑格挂上编辑器;而点同一格 / 同行换列只要 0.6ms —— 这与"点单元格时感觉慢"的体感完全对应,慢的是"第一次"和"换行"。
  5. 编辑提交额外卡:页面级 errorCount 是依赖深层响应式的 computed,每次写行字段都重跑「行数 × 22 列」全表校验:1000 行 31~69ms、5000 行 215~434ms,随行数线性恶化。
  6. 可回收的性能:把 21 个可编辑列全部摘掉 editor 后,纵向 18.2→25.2 FPS(+38%)、横向 32.2→43.3 FPS(+34%)。这部分是"常驻 customCell + 每格重复校验"的开销,有优化空间。
与另一份报告的差异(重要):目录下另有一份 table-test-性能排查报告.html(其他 AI 产出的,按用户说明不必参考)。它把滚动瓶颈归因为"浏览器端 style/layout/paint 占 67%,Vue JS 仅 7%",并据此建议优化 CSS。 本报告的冻结克隆对照给出了更直接的判据:同一份 DOM、同样的 CSS,只要不重渲染就是 60 FPS。也就是说那 67% 的浏览器端开销是被每帧重渲染触发出来的结果,不是独立原因 —— 优化 CSS 收益有限,减少"每帧被改动的单元格数"才是主线。两者在"逐帧重渲染是问题"这一点上其实一致,分歧只在归因层级。

一、测量方法与可信度

1.1 测的是真实页面,不是复刻页

dev 默认账号 demo_zhangwei 没有被授予 menu.table_test 权限,直接访问 /table-test 会被路由守卫送到 /403(web/src/router/index.ts:445-448)。 本报告的测量方式是:在 Playwright 测试进程内拦截 /api/data/loaddata 中 s_power / s_user_power 的响应,补上这一条授权,从而进入真实页面。

该拦截只存在于测量进程,应用代码与后端数据都没有改动。因此本报告的数据包含了真实的 DefaultLayout(侧栏 / 顶栏 / 页签)外壳,而非隔离环境。

1.2 先排除"测量环境造成的假象"

低帧率最容易被怀疑成 headless 无 GPU 的软件光栅化。实测三种渲染路径:

渲染路径WebGL Renderer纵向滚动横向滚动
headless(默认)ANGLE / SwiftShader(软件)13.4 / 11.6 FPS17.9 FPS
headless + 强制 D3D11ANGLE / NVIDIA GTX 10509.2 / 11.1 FPS17.8 FPS
headed(真实窗口)ANGLE / NVIDIA GTX 105013.9 / 14.9 FPS28.9 FPS
换成真实 GPU 后帧率没有变好(甚至略差)。结论:低帧率是真实存在的,不是软件渲染造成的测量假象。本报告其余数据均在 headed + 真实 GPU 下测得。

1.3 决定性对照实验:冻结克隆

把现场 .stk-table(1000 行、虚拟滚动开启、529 个 td)整棵子树克隆一份,塞进同样宽高的容器,然后滚克隆。克隆不受 Vue 管理,滚动它不触发任何 JS 重渲染:

被测对象纵向横向说明
live(Vue 管理,逐帧改 DOM) 14.6 / 16.8 / 17 FPS —— 页面默认状态
克隆 A:原样克隆 60 / 60 / 60 FPS 60 / 60 FPS 同一份 DOM、同一套 CSS
克隆 B:+ 去掉固定列 sticky 60.4 / 56.7 / 48.1 FPS 60.2 / 59.9 FPS CSS 变量剥离后无显著变化
克隆 C:+ 去掉格线背景渐变 61 / 60.1 / 60.1 FPS 60.1 / 60 FPS 同上
克隆 D:+ 去掉 sticky 与全部 box-shadow 60.2 / 59.7 / 58.6 FPS 60.2 / 59.5 FPS 同上

A/B/C/D 都在 60 FPS 附近 → 这套 CSS 本身不构成瓶颈。live 与 clone 的唯一差别就是"每帧有没有 JS 重渲染", 而这个差别值 3~4 倍帧率。这也就是本报告把主线定在"减少每帧重渲染"的依据。

1.4 可信度分级

结论依据可信度
滚动掉帧、点击卡顿真实存在真实页面 + 真实 GPU + 帧间隔采样高
瓶颈是每帧重渲染,而非 CSS/绘制冻结克隆对照(60 FPS vs 11~18 FPS)高
首次点击 394ms 源于 editingActive→列重建代码链路 + DOM 变更计数 + 分场景计时高
errorCount 是 O(行×列) 且随行数线性恶化1000/5000 行直接计时,5 组取值高
常驻 customCell 使帧率下降 34~38%交替 3 轮 A/B 取中位高
横向滚动的主要成本是固定列 sticky22.9 → 44.4 → 49.4 FPS,但开关同时改变了别的东西待确认
单帧内 JS / 样式 / 布局 / 绘制的精确占比CDP Trace 与 LoAF 归因互相不完全吻合,存在测量口径问题待确认
必须说明的测量局限:同一配置连续多轮测量,帧率会在 9~25 FPS 之间波动(GC、后台负载、温度都会影响)。 本报告中凡是"单项开关"的 A/B,都改成了交替多轮取中位(BASE→VAR→BASE→VAR…)。 早期几轮单次 A/B 的结论自相矛盾(例如"去掉格线渐变"一次显示变快、一次显示变慢),已作废、不作为依据 —— 冻结克隆实验正是为了替代这类不可靠的单轮对比。

二、实测数据

2.1 帧率(1000 行,视口 1600×900,表格容器约 1308×656)

场景中位帧率p50 帧p95 帧最差帧
静止(不滚动)61 FPS16.7ms18.0ms18.3ms
纵向滚动9~18 FPS
多数落在 11~15
63~74ms115~143msup to 500ms
横向滚动10~32 FPS
多数落在 20~26
37~49ms61~86ms120~4800ms
编辑态下纵向滚动8~11 FPS106ms249ms502ms

滚动期间实测 long task(>50ms)42 个,平均 77.8ms,最大 533ms —— 主线程被成片占满,这就是"FPS 特别低"的直接形态。

2.2 关键发现:代价与总行数无关

用页面自带的「数据量」下拉切换后测量:

数据量虚拟滚动渲染出的 DOM每帧 DOM 变更纵向横向
10 行关10 tr / 230 td0 节点 / 0 属性60.7 FPS39.7 FPS
100 行关(>100 才开)100 tr / 2300 td0 / 058.8 FPS7.8 FPS
1000 行开26 tr / 552 td12.8 / 1.911.7 FPS21.8 FPS
5000 行开26 tr / 552 td12.8 / 1.911.3 FPS21.5 FPS

2.3 点击进入编辑(关键:慢的只有"第一次"和"换行")

操作点击 → 编辑器进 DOMtbody 节点增删属性写入说明
页面加载后首次点任意格 298~394ms 966~1008 89~91 editingActive false→true
换行点击(×4 次) 94 / 187 / 116 / 182ms 70~72 25~37 编辑态在行间迁移
反复点同一格(×3 次) 0.6 / 0.7 / 0.4ms 0 22~23 无重挂载
同一行换列(×3 次) 0.7 / 0.8 / 0.6ms 0~2 38~44 只换焦点
编辑器根节点数量从非编辑态的 24 个跳到编辑态的 41~42 个 —— 也就是说点一格会让整行约 17 个可编辑格一起挂上编辑器,这是"换行点击 ~70 次 DOM 变更 / 100~190ms"的来源。

2.4 编辑提交链路

指标1000 行5000 行
写一个行字段(响应式写入本身)0.0~0.1ms0.0~0.1ms
errorCount 重算31.3 / 32.1 / 34.1ms(另测 68.7ms)215.1 / 434.2ms
失焦提交端到端(含错误格定位渲染)286.1ms465.3ms
点「保存明细」端到端318.1ms612.3ms
直接跑全表校验循环(21000 / 105000 次格检查)108.2ms541.3ms

errorCount 的小数据量标定(同一函数,仅改行数):

行数格检查次数重算耗时
102200.4ms
1002 2006.7ms
100022 00058ms
5000110 000215.1ms

严格线性 → 确认是 O(行数 × 列数),且完全由行数放大。

2.5 可回收的性能:摘掉 editor 的对照

交替 3 轮(BASE→VAR→BASE→VAR→BASE→VAR)取中位,在页面内存中动态增删 21 个列的 editor 配置:

配置纵向横向非编辑态 DOM
保留全部 editor(页面默认)18.2 FPS
18.2 / 14.4 / 20.8
32.2 FPS
32.2 / 31.5 / 34.1
23 编辑器根 + 207 展示 span / 529 td
移除全部 editor25.2 FPS
25.2 / 27.4 / 24.3
43.3 FPS
40.6 / 50.7 / 43.3
0 编辑器根 + 24 展示 span / 529 td
差值+38%+34%——

注意:这一项同时改变了"每格跑不跑 customCell"和"DOM 里有没有那个展示 span",两者都归因于 editor 特性本身,故仍算作 editor 层的开销; 但它也说明去掉编辑器不是可行的修法(行内编辑是产品能力),只能定向削减这部分开销。

三、根因分析

3.1 因果主线

  1. 滚动容器(.stk-table 自身,overflow: auto)触发原生 scroll。
  2. 底座的 onTableScroll 用 rAF 节流后调用 updateVirtualScrollY(stk-table-vue/src/StkTable/StkTable.vue:1927-1964),换算出新的 startIndex / endIndex,写回 virtualScroll 这个 shallowRef 并 triggerRef。
  3. 该 store 被模板消费 → StkTable 组件整体重渲染:表头 + 可见 25~26 行 × 22 列 ≈ 约 550 个单元格的 vnode 重建与 props / style 重写,另有约 12.8 个节点每帧进出 DOM。
  4. 每帧 550 格的内联样式(--cw / min-width / 固定列 left·right)被重写 → 浏览器每帧各做 1 次样式重算与布局(实测 RecalcStyleCount 与 LayoutCount 均约 1 次/帧),随后是大面积重绘。
  5. 叠加每格 displayValue 里重复跑的 validate / checkPrecision / formatDisplayValue,单帧落到 60~90ms,即 11~18 FPS。
  6. 反证:克隆对照(不重渲染)→ 60 FPS;10/100 行(虚拟关闭、零 DOM 变更)→ 58~60 FPS;行列数越大越慢而总行数无影响 → 全部指向第 3 步。

3.2 根因一:首次点击触发全表列重建(点击卡顿主因)

  1. mousedown → 底座选区变化 → handleAreaSelectionChange → activateEditing → editingActive.value = true(G3Table.vue:584-609、G3Table.vue:1255-1257)。
  2. displayColumns 依赖 editingActive.value(G3Table.vue:923-925),于是 22 个列全部 map 成新对象。
  3. resolveCustomCell 的注入判据里第一项就是 editingActive(utils/column.ts:121-134),因此 text / number / date 这些原本"非编辑态零组件开销"的列,此刻全部被挂上 customCell。
  4. 新数组传给底座 props.columns → 引用变化 → 底座 watch 触发 handleDealColumns() + updateMaxRowSpan(),并在 nextTick 里 initVirtualScrollX() + updateFixedShadow() + updateCustomScrollbar()(stk-table-vue/src/StkTable/StkTable.vue:1240-1252)。
  5. tableHeaders 更新 → cellStyleMap 重建(StkTable.vue:1433-1464,逐列重算 --cw / min-width / max-width / 固定列样式字符串)→ 全表重渲染 + 编辑器挂载 + 焦点恢复。

实测代价:394ms、966 次 tbody 节点变更。 注意这是每次页面加载只发生一次的成本;此后 editingActive 恒为 true,不再重建列 —— 这也解释了为什么"再点同一格"只要 0.6ms。

3.3 根因二:行级编辑态让整行挂编辑器(换行点击卡顿主因)

  1. 行编辑状态是行级的:activateEditing 把整行标记为 editing,并在 WeakMap 里存 focusCol(G3Table.vue:439-483、G3Table.vue:584-609)。
  2. createEditableCell 里只有 date 类型是"只给当前格挂编辑器",其余类型(text / number)在行编辑态下整行都挂(utils/editable.ts:1177-1204)。
  3. 于是换行点击 = 旧行约 17 个编辑器卸载 + 新行约 17 个挂载 → 70~72 次节点变更、94~187ms。

代码里已注明 date 必须"只挂当前格"的理由(切换格要重新挂载才能弹出日历面板,utils/editable.ts:1187-1201)。把同一策略推广到 text / number 是降低换行成本最直接的方向,但要一并处理键盘导航、Tab 跳格与焦点恢复这几条依赖"整行已挂载"的链路,见 §4.3 的取舍分析。

3.4 根因三:页面级 errorCount 全表校验(提交卡顿)

web/src/views/table-test/index.vue:615-625 的 errorCount 是 computed,遍历全部行 × 全部列,每格调用 validateCell(内含业务 validate + checkPrecision)。 由于 rows 是深层响应式 ref,任何行字段写入都会让它失效重算;而编辑提交(Enter / 失焦 / checkbox)恰恰就是写行字段。

这个 computed 的唯一消费者是工具栏那个「N 处待修正」徽章(index.vue:659-662)。 为了一次徽章更新,每次提交都要全表校验一遍 —— 1000 行 31~69ms、5000 行 215~434ms,是纯浪费。 另外 findFirstError(index.vue:535-551)在保存时已经独立跑了一遍同样的全表校验,两者重复。

3.5 根因四:每格重复求值 + 每格分配一个 computed

displayValue(utils/editable.ts:369-454)是常驻 customCell 在非编辑态的实际渲染函数, 它每次调用都会:跑业务 validate、跑 checkPrecision、跑 formatDisplayValue、构造 class 数组与 data 属性。 代码注释里明确写了不做任何缓存(utils/editable.ts:205-207),理由是避免"数据改了红底还在"的脏状态 —— 这个取舍在语义上是对的,但代价是每一帧、每一个可见格都重算一遍。

另外有一处纯浪费:每渲染一格就分配一个 computed。

// packages/ui/src/table/utils/editable.ts:375  —— 在 displayValue 函数体内
function displayValue(ctx, row, col, editor) {
  const { e, is } = useNamespace('table')   // ← 每次调用都新建一个 computed
  ...

// packages/ui/src/_composables/useNamespace.ts:46
const ns = computed(() => injected?.namespace.value ?? getGlobalConfig().namespace ?? COMPILE_TIME_NAMESPACE)

同样的写法还在 utils/editable.ts:717(createInput)与 utils/precision.ts:114(createFormatCell)。 按约 550 个可见格、每帧全部重渲染估算,每帧会新建数百个 computed 实例及其闭包; Trace 里 V8.GC_* 事件合计约占 4% 采样,与这个分配量吻合。

3.6 根因五:固定列 sticky 与横向滚动(待确认)

横向滚动单独测的一组数字:

配置横向帧率
页面默认(fixedColShadow=true,G3Table 的 sticky 补丁生效)22.9 FPS
内存里把 fixedColShadow 置 false24.1 FPS
?g3-fixed-patch=off(关掉 sticky 补丁)44.4 FPS
把所有 .fixed-cell 强制 position: static49.4 FPS

同时抓到了固定列 class 的翻转序列:fixed-cell--active fixed-cell fixed-cell--left ↔ fixed-cell fixed-cell--left,来源是底座的 updateFixedShadow (stk-table-vue/src/StkTable/useFixedCol.ts:92-165),它按滚动位置重算 fixedCols → fixedColClassMap → 所有固定列 td 的 class 重新 patch。

为什么标"待确认":① 这条的开关同时改变了"呈现什么"(关掉 sticky 后固定列不再吸附,视觉上就是另一个东西),不是干净的对照; ② class 翻转只在每轮测量的第一趟出现(1.73 次/帧),之后归零 —— 看起来是翻越边界时的瞬态,而非持续逐帧; ③ 早期"内存改 prop"的那一轮可能没有真正生效(改的是 props 对象,Vue 未必响应)。 所以 22.9 → 44~49 FPS 这个差值可能被高估,需要用只切 position、保留 z-index 的干净 A/B 重测(见 §5)。

另外,该 sticky 补丁本身是为了修"冻结列横滚抖动"而加的(packages/ui/src/table/style/_overrides.scss:416-477, 根因是底座只把 position: sticky 挂在滚动位置算出来的 --active 类上)。 所以"关掉补丁"不是可选修法,只能找等价但更省的实现。

3.7 根因六:次要项

四、修改方向

按「收益 / 风险」排序。P0 = 建议先做,P1 = 值得做但需配套验证,P2 = 顺手清理。

P0-1去掉 errorCount 的全表重算

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

现状依赖深层响应式 rows 的 computed,任何行字段写入都让全体「行数 × 22 列」重跑一遍校验,只为更新一个徽章。

改法三选一,按侵入度递增:

  • (a) 最省事:把 computed 换成普通 ref,只在「刷新 / 保存 / 切换数据量」时显式重算一次。徽章从"实时精确"变成"提交后更新",对演示页完全够用。
  • (b) 防抖:保留 computed,但外面套一个 watch(flush: 'post')+ rAF/200ms 防抖写入 ref,连续输入时只算最后一次。
  • (c) 只算可见行:结合虚拟窗口,只校验视口内若干行;或维护增量计数(改哪格只重算那一格,从总数里加减)。

预期1000 行每次提交省 31~69ms,5000 行省 215~434ms。

风险极低。它只影响一个展示徽章;findFirstError 在保存时仍会独立跑全表校验,提交把关不受影响。

验证点「保存明细」的端到端耗时(当前 1000 行 318ms / 5000 行 612ms)应显著下降。

P0-2阻断「首次点击 → 全表列重建」的级联

位置packages/ui/src/table/G3Table.vue:923-1004(displayColumns)与 packages/ui/src/table/utils/column.ts:82-134(resolveCustomCell)

现状全表布尔量 editingActive 一翻真,22 个列对象全部换新 → 底座 watch 触发几何重算 + 全表重渲染(394ms / 966 次 DOM 变更)。

改法(建议按这个顺序试)

  • (a) 先做归因测量,别直接改:把翻转前后的 displayColumns 做 Object.is 逐列比对,确认到底有多少列真的需要换新对象。 目前实现是无条件 props.columns.map(...),即使某列的 customCell 结果与上一轮完全相同,也会产出新对象 —— 只对"结果确实变化"的列产出新对象,就已经能缩小波及面。
  • (b) 两态记忆化:为 editingActive 的 true / false 各缓存一份已解析的列数组,翻转时直接复用缓存,避免重复跑 22 次 resolveCustomCell。 注意:这不能消除底座 watch(数组引用仍会变),收益主要在列解析本身,属小头。
  • (c) 真正的解法是"列不随编辑态变":让 customCell 的注入不依赖 editingActive,改由单元格渲染函数内部读一个响应式开关来决定"渲染展示态还是编辑器"。 这样翻真相位只变单元格内容,props.columns 引用恒定,底座的 handleDealColumns / cellStyleMap 全部不触发。

    但这条有代价:等于"永远注入 customCell",也就是 §2.5 测到的滚动变慢 34~38%。所以不能简单照做,需要配套:
    • 给"非编辑态"写一条极廉价的展示分支(直接渲染纯文本,不建 span、不跑 validate / precision / 状态类),把每格的固定成本压到接近库原生 td;
    • 把校验 / 精度结果做成值键控缓存(见 P1-1),消除重复求值。
    这两条做到位之后,"永远注入但展示态极廉价"有可能同时赢下点击延迟和滚动帧率 —— 这是最值得投入的方向,但必须用数据验证,不能假定。

预期首次点击 394ms → 目标 < 50ms((c) 若能落地,理论上可降到与"点同一格"同档的亚毫秒级)。

风险(c) 面较大,牵动编辑态渲染、校验态可见性(非编辑行也要能看见红底,这是刻意设计,见 column.ts:96-107)与固定列灰底标记,需要 e2e 兜底。建议先落 (a) 拿到真实收益数据再决定是否推进 (c)。

验证packages/ui/e2e/table.spec.ts 已有的行内编辑用例;外加本报告 §5 的"首次点击 → 编辑器就绪"计时。

P1-1把每格的校验 / 精度求值改成值键控缓存

位置packages/ui/src/table/utils/editable.ts:369-454(displayValue → resolveEditorStatus → checkPrecision → formatDisplayValue)

现状注释明确写了"不做任何缓存",于是每一帧、每一个可见格都要重跑一遍业务 validate 与精度判定。

改法加一层 值键控缓存,例如 WeakMap<row, Map<dataIndex, { value, status }>>,命中条件是 Object.is(cachedValue, row[dataIndex])。值没变就直接复用上次的 { level, message }。

为什么这不违背原注释的意图原注释担心的是"数据改了红底还在"的脏状态;值键控缓存把"值"作为键,值一变缓存立即失效,语义与"每次重算"完全等价,只是省掉了值没变时的重复计算。滚动时绝大多数格的值都没变,正是收益所在。

预期常驻 customCell 的单帧成本显著下降;这是 §2.5 那 34~38% 里最可回收的一块。

风险低,但要注意:validate 的第 2 个参数是整行 row(用于跨列校验,如"目的港不能与起运港相同",index.vue:344-345),所以缓存键不能只看本格值 —— 需要把"依赖同行其它列"的情形也纳入失效条件(保守做法:按行做版本号,行内任一字段变更即整行失效)。

P1-2消除每格一个 computed 的分配

位置packages/ui/src/table/utils/editable.ts:375、utils/editable.ts:717、utils/precision.ts:114

现状三处都在每格渲染的调用路径上执行 const { e, is } = useNamespace('table'),而 useNamespace 内部会 computed(...)(_composables/useNamespace.ts:46)并创建若干闭包。按约 550 个可见格 × 每帧重渲染估算,每帧新建数百个 computed 实例。

改法把命名空间取值提到模块级或列解析期(resolveCustomCell 时算一次,闭包捕获),单元格渲染函数只读结果。命名空间在同一棵树上不会逐格变化,按格重新解析没有意义。

预期降低 GC 压力(Trace 中 V8.GC_* 约占 4% 采样)与每格的固定开销。

风险低。唯一要留意的是 useNamespace 会在 dev 下做一次命名空间一致性 console.warn(useNamespace.ts:52-60),提到模块级后该警告的触发次数会变,但不影响功能。

P1-3评估"只给当前格挂编辑器"能否推广到 text / number

位置packages/ui/src/table/utils/editable.ts:1177-1204(createEditableCell)

现状只有 date 是"仅当前格挂编辑器",text / number 在行编辑态下整行挂载 → 换行点击 70 次 DOM 变更 / 94~187ms。

改法把 date 的策略推广到 text / number:只有 ctx.shouldFocus(row, col) 为真的那一格挂编辑器,其余格走 displayValue。

为什么值得试换行点击的 DOM 变更量可望从约 70 降到个位数(对齐"点同一格 0.6ms"的量级)。

风险(需要认真评估)代码里已写明 date 之所以特殊,是因为"切换格时新格是挂载,onVnodeMounted 才会发生"(editable.ts:1187-1201)。 整行挂载很可能被键盘导航、Tab 跳格、焦点恢复、Enter 下移这几条链路隐式依赖(它们可能假定目标格已有编辑器实例)。改动前应先用 e2e 跑通这些路径,或先做实验性开关对照。

验证packages/ui/e2e/table.spec.ts 的键盘导航 / Tab 用例,以及"进入编辑行高不变"这类几何用例。

P1-4用干净 A/B 确认固定列 sticky 的横向成本

位置packages/ui/src/table/style/_overrides.scss:416-477(sticky 补丁)、stk-table-vue/src/StkTable/useFixedCol.ts:92-165(updateFixedShadow)

现状横向 22.9 FPS;关掉补丁 44.4 FPS;全部 static 49.4 FPS。但该对照不干净(关掉 sticky 等于关掉固定列功能)。

改法(先测量,后决定)

  • 只切 position、保留 z-index,其余 CSS 完全不动,交替多轮取中位;
  • 用 CDP 计数 RecalcStyleCount / LayoutCount / Paint 定位成本落在哪一层;
  • 若确认 sticky 是主因,考虑:把 updateFixedShadow 的 class 翻转收敛为只在跨越边界时发生(useFixedCol.ts:136-152 的判据用的是滚动位置,本就是边界敏感的),或改用 CSS 变量 / :nth-child 表达阴影,避免整列 td 的 class 反复 patch。

风险中。sticky 补丁是为修"冻结列横滚抖动"而存在的真实修复,任何替换都必须先复现并验证那个抖动不再出现。

P2-1让 will-change: transform 只在 transform 滚动模式下生效

位置packages/ui/src/table/G3Table.vue:1936-1940

现状无条件给约 28000px 高的 .stk-tbody-main 挂 will-change: transform,但注释自己写明这是为 "transform 虚拟滚动模式"准备的;本页走原生滚动(scrollbar: false),库也只在该模式下才加这条(stk-table-vue/src/StkTable/style.less:392-396)。

改法把这条规则限定到实验性滚动模式(库的 .exp-scroll-y)之下,与底座行为对齐。

预期收益不大(属次要项),但能省掉一个 28000px 高的大合成层。

风险低;需确认没有别的地方依赖这层提升(例如某些浏览器的平滑度)。

P2-2注意 100 行这个"两头不讨好"的区间

位置packages/ui/src/table/G3Table.vue:251(MIN_VIRTUAL_ROW_COUNT = 100)

现状门控是 > 100 才开虚拟滚动;100 行正好落在关闭一侧,全量渲染 2300 个 td。实测纵向 58.8 FPS(可接受),但横向只有 7.8 FPS —— 本报告测到的最差横向值。

改法这不一定要改门控(门控是为了"行数略超一屏时最后几行被吞掉"的确定性渲染,见同处注释)。可选:把阈值下调,或在有固定列且总列宽溢出时改用更保守的门控。建议先记录为已知边界。

风险改门控会影响渲染确定性,需重跑"行数 100~110 时最后一行可见"这类用例。

五、如何验证(可复现)

本次排查新增了一批只读探针脚本,都放在 g3soft-libs/packages/ui/ 下,需要时可直接重跑(前置:后端 8088、前端 dev server 5090 已启动):

脚本作用
probe-table-perf.mjs总入口:DOM 几何、纵/横向帧率、点击延迟、CPU profile
probe-table-trace.mjs / probe-table-loaf.mjs用 CDP Trace / Long Animation Frames 做逐帧归因
probe-table-decisive.mjs冻结克隆对照(本报告最关键的证据)
probe-table-gpu.mjsheadless / 强制 GPU / headed 三种渲染路径对照
probe-table-final.mjseditor 增删 A/B、点击分场景、errorCount 与保存耗时
probe-table-churn.mjs横向滚动 class 抖动与 sticky 对照
probe-table-prod.mjs生产构建对照(未完成,见 §6)

建议的回归验证口径(修完后对照本报告基线的数值):

  1. 静止 61 FPS、纵向 12~18 FPS、横向 18~32 FPS、编辑态 8~11 FPS;
  2. 首次点击 394ms / 966 次 DOM 变更;换行点击 94~187ms;同格点击 0.6ms;
  3. errorCount 重算 1000 行 31~69ms、5000 行 215~434ms;保存端到端 318ms / 612ms;
  4. 摘掉 editor 的对照值 25.2 FPS(纵向)/ 43.3 FPS(横向)—— 修完后应显著接近这组数,而不是被它反超。

提示:直接访问 /table-test 会因缺 menu.table_test 授权被送到 /403。 要么用探针脚本里的权限响应拦截,要么给 demo_zhangwei 补一条授权(sql/20_registration/13_table_test_menu.sql)。

六、本次没做的事 / 已知不确定