Files
workspace/code/g3soft-erp/web/table-test-性能报告与修改方向.html
T
2026-10-16 17:30:17 +08:00

693 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>/table-test 表格性能报告与修改方向</title>
<style>
:root {
--text: #1f2329;
--text-2: #4e5969;
--text-3: #86909c;
--border: #e5e6eb;
--bg: #f7f8fa;
--card: #ffffff;
--primary: #165dff;
--primary-soft: #f0f5ff;
--primary-border: #bedaff;
--danger: #ec4246;
--danger-soft: #ffece8;
--warning: #e79a0d;
--warning-soft: #fff7e8;
--warning-border: #f5d8a8;
--ok: #00b42a;
--ok-soft: #e8ffea;
--code-bg: #f2f3f5;
--mono: Consolas, Monaco, "Courier New", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 40px 24px 80px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.78;
}
.page { max-width: 1000px; margin: 0 auto; }
header.doc {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px 32px;
margin-bottom: 20px;
}
header.doc h1 { margin: 0 0 10px; font-size: 22px; line-height: 1.4; }
header.doc .meta { color: var(--text-3); font-size: 13px; }
header.doc .meta span { display: inline-block; margin-right: 20px; }
section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px 32px;
margin-bottom: 20px;
}
h2 {
font-size: 17px;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
h3 { font-size: 15px; margin: 26px 0 10px; }
h3:first-of-type { margin-top: 10px; }
h4 { font-size: 14px; margin: 18px 0 6px; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0; padding-left: 24px; }
li { margin: 5px 0; }
code {
background: var(--code-bg);
border-radius: 4px;
padding: 1px 6px;
font-family: var(--mono);
font-size: 12.5px;
color: #c0341d;
word-break: break-all;
}
pre {
background: #1f2329;
color: #e5e6eb;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.6;
margin: 12px 0;
}
pre code { background: none; color: inherit; padding: 0; font-size: 12.5px; }
.file { color: var(--primary); font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 13px;
}
th, td {
border: 1px solid var(--border);
padding: 7px 12px;
text-align: left;
vertical-align: top;
}
th { background: #f2f3f5; font-weight: 600; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.bad { color: var(--danger); font-weight: 600; }
.good { color: var(--ok); font-weight: 600; }
.warn { color: var(--warning); font-weight: 600; }
.tldr {
background: var(--primary-soft);
border: 1px solid var(--primary-border);
border-radius: 8px;
padding: 16px 20px;
margin: 12px 0;
}
.tldr ol { margin: 6px 0 0; padding-left: 22px; }
.note {
background: var(--warning-soft);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 12px 16px;
margin: 14px 0;
font-size: 13px;
color: #6b5300;
}
.note b { color: #6b5300; }
.key {
background: var(--ok-soft);
border: 1px solid #aff0b5;
border-radius: 8px;
padding: 12px 16px;
margin: 14px 0;
font-size: 13.5px;
}
.chain { counter-reset: step; list-style: none; padding-left: 0; margin: 12px 0; }
.chain > li { counter-increment: step; position: relative; padding-left: 34px; margin: 11px 0; }
.chain > li::before {
content: counter(step);
position: absolute; left: 0; top: 3px;
width: 22px; height: 22px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 12px;
line-height: 22px;
text-align: center;
font-weight: 600;
}
.tag {
display: inline-block;
font-size: 11.5px;
font-weight: 600;
border-radius: 4px;
padding: 1px 7px;
margin-right: 6px;
vertical-align: 1px;
white-space: nowrap;
}
.tag.p0 { background: var(--danger-soft); color: var(--danger); }
.tag.p1 { background: var(--warning-soft); color: var(--warning); }
.tag.p2 { background: var(--ok-soft); color: var(--ok); }
.tag.sure { background: #e8f3ff; color: var(--primary); }
.tag.maybe { background: #f2f3f5; color: var(--text-2); }
.fix {
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: 8px;
padding: 16px 20px;
margin: 16px 0;
background: #fcfcfd;
}
.fix.p0 { border-left-color: var(--danger); }
.fix.p1 { border-left-color: var(--warning); }
.fix.p2 { border-left-color: var(--ok); }
.fix h4 { margin: 0 0 8px; font-size: 14.5px; }
.fix p { margin: 7px 0; color: var(--text-2); font-size: 13.5px; }
.fix ul { margin: 7px 0; color: var(--text-2); font-size: 13.5px; }
.fix .label {
display: inline-block; min-width: 62px; color: var(--text-3);
font-size: 12.5px; font-weight: 600;
}
footer { color: var(--text-3); font-size: 12.5px; text-align: center; margin-top: 8px; }
.kv { color: var(--text-3); font-size: 12.5px; }
</style>
</head>
<body>
<div class="page">
<header class="doc">
<h1>/table-test 表格性能报告与修改方向</h1>
<div class="meta">
<span>日期:2026-10-16</span>
<span>状态:<b>已定位根因,未改动任何业务代码</b></span>
</div>
<div class="meta" style="margin-top:6px">
<span>对象:<span class="file">g3soft-erp/web/src/views/table-test/index.vue</span></span>
<span>组件:<span class="file">g3soft-libs/packages/ui/src/table/G3Table.vue</span></span>
<span>底座:stk-table-vue 1.2.7</span>
</div>
</header>
<!-- ============================================================ -->
<section>
<h2>摘要(TL;DR)</h2>
<div class="tldr">
<ol>
<li><b>不是 CSS/绘制慢,是每帧重渲染慢。</b>决定性证据:把表格 DOM 原样 <code>cloneNode(true)</code> 出来放进同样尺寸的容器里滚动 → <b>60 FPS 满帧</b>;同一份 DOM 由 Vue 管着滚 → <b>11~18 FPS</b>。CSS(sticky / 渐变格线 / box-shadow / <code>:has()</code>)全被这一条排除。</li>
<li><b>滚动卡</b>:虚拟滚动窗口每帧变化 → 整个表格组件重渲染 → 约 550 个可见单元格的 props / style 重新写入 → 浏览器每帧重算样式 + 重排 + 重绘,单帧 60~90ms。代价与<b>总行数无关</b>(1000 行与 5000 行帧率相同),只与<b>渲染出的单元格数</b>有关。</li>
<li><b>点击卡</b>:页面加载后<b>第一次</b>点任意格,全表布尔量 <code>editingActive</code> 由 false 翻 true → <code>displayColumns</code> 整体重建为 22 个新列对象 → 底座 watch 触发 <code>handleDealColumns()</code> + <code>initVirtualScrollX()</code> + <code>updateFixedShadow()</code> → 实测 <b>394ms / 966 次 DOM 变更</b>。</li>
<li><b>换行点击</b>同样是 100~190ms(约 70 次 DOM 变更),因为行级编辑态会让<b>整行</b>可编辑格挂上编辑器;而<b>点同一格 / 同行换列只要 0.6ms</b> —— 这与"点单元格时感觉慢"的体感完全对应,慢的是"第一次"和"换行"。</li>
<li><b>编辑提交额外卡</b>:页面级 <code>errorCount</code> 是依赖深层响应式的 computed,每次写行字段都重跑「行数 × 22 列」全表校验:1000 行 <b>31~69ms</b>、5000 行 <b>215~434ms</b>,随行数线性恶化。</li>
<li><b>可回收的性能</b>:把 21 个可编辑列全部摘掉 editor 后,纵向 18.2→<b>25.2 FPS</b>(+38%)、横向 32.2→<b>43.3 FPS</b>(+34%)。这部分是"常驻 customCell + 每格重复校验"的开销,有优化空间。</li>
</ol>
</div>
<div class="note">
<b>与另一份报告的差异(重要)</b>:目录下另有一份 <code>table-test-性能排查报告.html</code>(其他 AI 产出的,按用户说明不必参考)。它把滚动瓶颈归因为"浏览器端 style/layout/paint 占 67%,Vue JS 仅 7%",并据此建议优化 CSS。
本报告的<b>冻结克隆对照</b>给出了更直接的判据:<b>同一份 DOM、同样的 CSS,只要不重渲染就是 60 FPS</b>。也就是说那 67% 的浏览器端开销是<b>被每帧重渲染触发出来的结果</b>,不是独立原因 —— 优化 CSS 收益有限,减少"每帧被改动的单元格数"才是主线。两者在"逐帧重渲染是问题"这一点上其实一致,分歧只在归因层级。
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>一、测量方法与可信度</h2>
<h3>1.1 测的是真实页面,不是复刻页</h3>
<p>
dev 默认账号 <code>demo_zhangwei</code> 没有被授予 <code>menu.table_test</code> 权限,直接访问
<code>/table-test</code> 会被路由守卫送到 <code>/403</code>(<span class="file">web/src/router/index.ts:445-448</span>)。
本报告的测量方式是:在 <b>Playwright 测试进程内</b>拦截 <code>/api/data/loaddata</code> 中
<code>s_power</code> / <code>s_user_power</code> 的响应,补上这一条授权,从而进入真实页面。
</p>
<p class="kv">
该拦截只存在于测量进程,<b>应用代码与后端数据都没有改动</b>。因此本报告的数据包含了真实的
DefaultLayout(侧栏 / 顶栏 / 页签)外壳,而非隔离环境。
</p>
<h3>1.2 先排除"测量环境造成的假象"</h3>
<p>低帧率最容易被怀疑成 headless 无 GPU 的软件光栅化。实测三种渲染路径:</p>
<table>
<tr><th>渲染路径</th><th>WebGL Renderer</th><th>纵向滚动</th><th>横向滚动</th></tr>
<tr><td>headless(默认)</td><td>ANGLE / SwiftShader(软件)</td><td class="num bad">13.4 / 11.6 FPS</td><td class="num bad">17.9 FPS</td></tr>
<tr><td>headless + 强制 D3D11</td><td>ANGLE / <b>NVIDIA GTX 1050</b></td><td class="num bad">9.2 / 11.1 FPS</td><td class="num bad">17.8 FPS</td></tr>
<tr><td>headed(真实窗口)</td><td>ANGLE / <b>NVIDIA GTX 1050</b></td><td class="num bad">13.9 / 14.9 FPS</td><td class="num">28.9 FPS</td></tr>
</table>
<div class="key">
换成真实 GPU 后帧率<b>没有变好</b>(甚至略差)。结论:低帧率是真实存在的,不是软件渲染造成的测量假象。本报告其余数据均在 <b>headed + 真实 GPU</b> 下测得。
</div>
<h3>1.3 决定性对照实验:冻结克隆</h3>
<p>把现场 <code>.stk-table</code>(1000 行、虚拟滚动开启、529 个 td)整棵子树克隆一份,塞进同样宽高的容器,然后滚<b>克隆</b>。克隆不受 Vue 管理,滚动它不触发任何 JS 重渲染:</p>
<table>
<tr><th>被测对象</th><th>纵向</th><th>横向</th><th>说明</th></tr>
<tr>
<td>live(Vue 管理,逐帧改 DOM)</td>
<td class="num bad">14.6 / 16.8 / 17 FPS</td>
<td class="num bad">——</td>
<td>页面默认状态</td>
</tr>
<tr>
<td><b>克隆 A:原样克隆</b></td>
<td class="num good">60 / 60 / 60 FPS</td>
<td class="num good">60 / 60 FPS</td>
<td><b>同一份 DOM、同一套 CSS</b></td>
</tr>
<tr>
<td>克隆 B:+ 去掉固定列 sticky</td>
<td class="num good">60.4 / 56.7 / 48.1 FPS</td>
<td class="num good">60.2 / 59.9 FPS</td>
<td>CSS 变量剥离后无显著变化</td>
</tr>
<tr>
<td>克隆 C:+ 去掉格线背景渐变</td>
<td class="num good">61 / 60.1 / 60.1 FPS</td>
<td class="num good">60.1 / 60 FPS</td>
<td>同上</td>
</tr>
<tr>
<td>克隆 D:+ 去掉 sticky 与全部 box-shadow</td>
<td class="num good">60.2 / 59.7 / 58.6 FPS</td>
<td class="num good">60.2 / 59.5 FPS</td>
<td>同上</td>
</tr>
</table>
<p>
A/B/C/D 都在 60 FPS 附近 → <b>这套 CSS 本身不构成瓶颈</b>。live 与 clone 的唯一差别就是"每帧有没有 JS 重渲染",
而这个差别值 3~4 倍帧率。这也就是本报告把主线定在"减少每帧重渲染"的依据。
</p>
<h3>1.4 可信度分级</h3>
<table>
<tr><th>结论</th><th>依据</th><th>可信度</th></tr>
<tr><td>滚动掉帧、点击卡顿真实存在</td><td>真实页面 + 真实 GPU + 帧间隔采样</td><td><span class="tag sure">高</span></td></tr>
<tr><td>瓶颈是每帧重渲染,而非 CSS/绘制</td><td>冻结克隆对照(60 FPS vs 11~18 FPS)</td><td><span class="tag sure">高</span></td></tr>
<tr><td>首次点击 394ms 源于 editingActive→列重建</td><td>代码链路 + DOM 变更计数 + 分场景计时</td><td><span class="tag sure">高</span></td></tr>
<tr><td>errorCount 是 O(行×列) 且随行数线性恶化</td><td>1000/5000 行直接计时,5 组取值</td><td><span class="tag sure">高</span></td></tr>
<tr><td>常驻 customCell 使帧率下降 34~38%</td><td>交替 3 轮 A/B 取中位</td><td><span class="tag sure">高</span></td></tr>
<tr><td>横向滚动的主要成本是固定列 sticky</td><td>22.9 → 44.4 → 49.4 FPS,但开关同时改变了别的东西</td><td><span class="tag maybe">待确认</span></td></tr>
<tr><td>单帧内 JS / 样式 / 布局 / 绘制的精确占比</td><td>CDP Trace 与 LoAF 归因互相不完全吻合,存在测量口径问题</td><td><span class="tag maybe">待确认</span></td></tr>
</table>
<div class="note">
<b>必须说明的测量局限</b>:同一配置连续多轮测量,帧率会在 <b>9~25 FPS</b> 之间波动(GC、后台负载、温度都会影响)。
本报告中凡是"单项开关"的 A/B,都改成了<b>交替多轮取中位</b>(BASE→VAR→BASE→VAR…)。
早期几轮单次 A/B 的结论自相矛盾(例如"去掉格线渐变"一次显示变快、一次显示变慢),已作废、不作为依据 —— 冻结克隆实验正是为了替代这类不可靠的单轮对比。
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>二、实测数据</h2>
<h3>2.1 帧率(1000 行,视口 1600×900,表格容器约 1308×656)</h3>
<table>
<tr><th>场景</th><th>中位帧率</th><th>p50 帧</th><th>p95 帧</th><th>最差帧</th></tr>
<tr><td>静止(不滚动)</td><td class="num good">61 FPS</td><td class="num">16.7ms</td><td class="num">18.0ms</td><td class="num">18.3ms</td></tr>
<tr><td>纵向滚动</td><td class="num bad">9~18 FPS<div class="kv">多数落在 11~15</div></td><td class="num bad">63~74ms</td><td class="num bad">115~143ms</td><td class="num bad">up to 500ms</td></tr>
<tr><td>横向滚动</td><td class="num bad">10~32 FPS<div class="kv">多数落在 20~26</div></td><td class="num bad">37~49ms</td><td class="num bad">61~86ms</td><td class="num bad">120~4800ms</td></tr>
<tr><td>编辑态下纵向滚动</td><td class="num bad">8~11 FPS</td><td class="num bad">106ms</td><td class="num bad">249ms</td><td class="num bad">502ms</td></tr>
</table>
<p class="kv">滚动期间实测 long task(&gt;50ms)42 个,平均 77.8ms,最大 533ms —— 主线程被成片占满,这就是"FPS 特别低"的直接形态。</p>
<h3>2.2 关键发现:代价与总行数无关</h3>
<p>用页面自带的「数据量」下拉切换后测量:</p>
<table>
<tr><th>数据量</th><th>虚拟滚动</th><th>渲染出的 DOM</th><th>每帧 DOM 变更</th><th>纵向</th><th>横向</th></tr>
<tr><td>10 行</td><td>关</td><td>10 tr / 230 td</td><td class="num good">0 节点 / 0 属性</td><td class="num good">60.7 FPS</td><td class="num">39.7 FPS</td></tr>
<tr><td>100 行</td><td>关(<code>&gt;100</code> 才开)</td><td>100 tr / 2300 td</td><td class="num good">0 / 0</td><td class="num good">58.8 FPS</td><td class="num bad">7.8 FPS</td></tr>
<tr><td>1000 行</td><td><b>开</b></td><td>26 tr / 552 td</td><td class="num bad">12.8 / 1.9</td><td class="num bad">11.7 FPS</td><td class="num">21.8 FPS</td></tr>
<tr><td>5000 行</td><td><b>开</b></td><td>26 tr / 552 td</td><td class="num bad">12.8 / 1.9</td><td class="num bad">11.3 FPS</td><td class="num">21.5 FPS</td></tr>
</table>
<ul>
<li><b>1000 行与 5000 行帧率几乎相同</b>(11.7 vs 11.3)→ 开销只跟"渲染出的单元格数"和"每帧改动的单元格数"有关。<b>加数据量不是问题,虚拟滚动开着才是。</b></li>
<li>反过来,10 / 100 行因为虚拟滚动<b>关闭</b>、滚动时零 DOM 变更,纵向几乎满帧(60.7 / 58.8)—— 这从另一侧证明了"每帧重渲染"是主因。</li>
<li><b>100 行是一个糟糕的区间</b>:虚拟门控是 <code>&gt;100</code> 才开(<span class="file">G3Table.vue:251</span>),所以 100 行会全量渲染 2300 个 td;纵向还行,<b>横向只有 7.8 FPS</b>(全表 2300 个 td 里含 400 个 sticky 固定列格,横向平移要整片重绘)。这是本报告见到的横向最差值。</li>
</ul>
<h3>2.3 点击进入编辑(关键:慢的只有"第一次"和"换行")</h3>
<table>
<tr><th>操作</th><th>点击 → 编辑器进 DOM</th><th>tbody 节点增删</th><th>属性写入</th><th>说明</th></tr>
<tr>
<td><b>页面加载后首次点任意格</b></td>
<td class="num bad">298~394ms</td>
<td class="num bad">966~1008</td>
<td class="num">89~91</td>
<td><code>editingActive</code> false→true</td>
</tr>
<tr>
<td>换行点击(×4 次)</td>
<td class="num bad">94 / 187 / 116 / 182ms</td>
<td class="num bad">70~72</td>
<td class="num">25~37</td>
<td>编辑态在行间迁移</td>
</tr>
<tr>
<td>反复点同一格(×3 次)</td>
<td class="num good">0.6 / 0.7 / 0.4ms</td>
<td class="num good">0</td>
<td class="num">22~23</td>
<td>无重挂载</td>
</tr>
<tr>
<td>同一行换列(×3 次)</td>
<td class="num good">0.7 / 0.8 / 0.6ms</td>
<td class="num good">0~2</td>
<td class="num">38~44</td>
<td>只换焦点</td>
</tr>
</table>
<div class="key">
编辑器根节点数量从非编辑态的 <b>24</b> 个跳到编辑态的 <b>41~42</b> 个 —— 也就是说<b>点一格会让整行约 17 个可编辑格一起挂上编辑器</b>,这是"换行点击 ~70 次 DOM 变更 / 100~190ms"的来源。
</div>
<h3>2.4 编辑提交链路</h3>
<table>
<tr><th>指标</th><th>1000 行</th><th>5000 行</th></tr>
<tr><td>写一个行字段(响应式写入本身)</td><td class="num good">0.0~0.1ms</td><td class="num good">0.0~0.1ms</td></tr>
<tr><td><code>errorCount</code> 重算</td><td class="num warn">31.3 / 32.1 / 34.1ms(另测 68.7ms)</td><td class="num bad">215.1 / 434.2ms</td></tr>
<tr><td>失焦提交端到端(含错误格定位渲染)</td><td class="num warn">286.1ms</td><td class="num bad">465.3ms</td></tr>
<tr><td>点「保存明细」端到端</td><td class="num warn">318.1ms</td><td class="num bad">612.3ms</td></tr>
<tr><td>直接跑全表校验循环(21000 / 105000 次格检查)</td><td class="num warn">108.2ms</td><td class="num bad">541.3ms</td></tr>
</table>
<p><code>errorCount</code> 的小数据量标定(同一函数,仅改行数):</p>
<table>
<tr><th>行数</th><th>格检查次数</th><th>重算耗时</th></tr>
<tr><td>10</td><td class="num">220</td><td class="num good">0.4ms</td></tr>
<tr><td>100</td><td class="num">2 200</td><td class="num good">6.7ms</td></tr>
<tr><td>1000</td><td class="num">22 000</td><td class="num warn">58ms</td></tr>
<tr><td>5000</td><td class="num">110 000</td><td class="num bad">215.1ms</td></tr>
</table>
<p class="kv">严格线性 → 确认是 O(行数 × 列数),且完全由行数放大。</p>
<h3>2.5 可回收的性能:摘掉 editor 的对照</h3>
<p>交替 3 轮(BASE→VAR→BASE→VAR→BASE→VAR)取中位,在页面内存中动态增删 21 个列的 <code>editor</code> 配置:</p>
<table>
<tr><th>配置</th><th>纵向</th><th>横向</th><th>非编辑态 DOM</th></tr>
<tr><td>保留全部 editor(页面默认)</td><td class="num bad">18.2 FPS<div class="kv">18.2 / 14.4 / 20.8</div></td><td class="num bad">32.2 FPS<div class="kv">32.2 / 31.5 / 34.1</div></td><td class="num">23 编辑器根 + 207 展示 span / 529 td</td></tr>
<tr><td>移除全部 editor</td><td class="num good">25.2 FPS<div class="kv">25.2 / 27.4 / 24.3</div></td><td class="num good">43.3 FPS<div class="kv">40.6 / 50.7 / 43.3</div></td><td class="num">0 编辑器根 + 24 展示 span / 529 td</td></tr>
<tr><td><b>差值</b></td><td class="num good"><b>+38%</b></td><td class="num good"><b>+34%</b></td><td class="num">——</td></tr>
</table>
<p class="kv">
注意:这一项同时改变了"每格跑不跑 customCell"和"DOM 里有没有那个展示 span",两者都归因于 editor 特性本身,故仍算作 editor 层的开销;
但它也说明<b>去掉编辑器不是可行的修法</b>(行内编辑是产品能力),只能定向削减这部分开销。
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>三、根因分析</h2>
<h3>3.1 因果主线</h3>
<ol class="chain">
<li>滚动容器(<code>.stk-table</code> 自身,<code>overflow: auto</code>)触发原生 scroll。</li>
<li>底座的 <code>onTableScroll</code> 用 rAF 节流后调用 <code>updateVirtualScrollY</code>(<span class="file">stk-table-vue/src/StkTable/StkTable.vue:1927-1964</span>),换算出新的 <code>startIndex / endIndex</code>,写回 <code>virtualScroll</code> 这个 shallowRef 并 <code>triggerRef</code>。</li>
<li>该 store 被模板消费 → <b>StkTable 组件整体重渲染</b>:表头 + 可见 25~26 行 × 22 列 ≈ <b>约 550 个单元格</b>的 vnode 重建与 props / style 重写,另有约 12.8 个节点每帧进出 DOM。</li>
<li>每帧 550 格的内联样式(<code>--cw</code> / min-width / 固定列 left·right)被重写 → 浏览器每帧各做 1 次样式重算与布局(实测 <code>RecalcStyleCount</code> 与 <code>LayoutCount</code> 均约 1 次/帧),随后是大面积重绘。</li>
<li>叠加每格 <code>displayValue</code> 里重复跑的 <code>validate</code> / <code>checkPrecision</code> / <code>formatDisplayValue</code>,单帧落到 <b>60~90ms</b>,即 <b>11~18 FPS</b>。</li>
<li><b>反证</b>:克隆对照(不重渲染)→ 60 FPS;10/100 行(虚拟关闭、零 DOM 变更)→ 58~60 FPS;行列数越大越慢而总行数无影响 → 全部指向第 3 步。</li>
</ol>
<h3>3.2 根因一:首次点击触发全表列重建(点击卡顿主因)</h3>
<ol class="chain">
<li>mousedown → 底座选区变化 → <code>handleAreaSelectionChange</code> → <code>activateEditing</code> → <code>editingActive.value = true</code>(<span class="file">G3Table.vue:584-609</span>、<span class="file">G3Table.vue:1255-1257</span>)。</li>
<li><code>displayColumns</code> 依赖 <code>editingActive.value</code>(<span class="file">G3Table.vue:923-925</span>),于是 22 个列<b>全部 map 成新对象</b>。</li>
<li><code>resolveCustomCell</code> 的注入判据里第一项就是 <code>editingActive</code>(<span class="file">utils/column.ts:121-134</span>),因此 text / number / date 这些原本"非编辑态零组件开销"的列,此刻全部被挂上 <code>customCell</code>。</li>
<li>新数组传给底座 <code>props.columns</code> → 引用变化 → 底座 watch 触发 <code>handleDealColumns()</code> + <code>updateMaxRowSpan()</code>,并在 nextTick 里 <code>initVirtualScrollX()</code> + <code>updateFixedShadow()</code> + <code>updateCustomScrollbar()</code>(<span class="file">stk-table-vue/src/StkTable/StkTable.vue:1240-1252</span>)。</li>
<li><code>tableHeaders</code> 更新 → <code>cellStyleMap</code> 重建(<span class="file">StkTable.vue:1433-1464</span>,逐列重算 <code>--cw</code> / min-width / max-width / 固定列样式字符串)→ <b>全表重渲染 + 编辑器挂载 + 焦点恢复</b>。</li>
</ol>
<p>
实测代价:<b>394ms、966 次 tbody 节点变更</b>。
注意这是<b>每次页面加载只发生一次</b>的成本;此后 <code>editingActive</code> 恒为 true,不再重建列 —— 这也解释了为什么"再点同一格"只要 0.6ms。
</p>
<h3>3.3 根因二:行级编辑态让整行挂编辑器(换行点击卡顿主因)</h3>
<ol class="chain">
<li>行编辑状态是<b>行级</b>的:<code>activateEditing</code> 把整行标记为 editing,并在 WeakMap 里存 <code>focusCol</code>(<span class="file">G3Table.vue:439-483</span>、<span class="file">G3Table.vue:584-609</span>)。</li>
<li><code>createEditableCell</code> 里只有 <code>date</code> 类型是"只给当前格挂编辑器",其余类型(text / number)在行编辑态下<b>整行都挂</b>(<span class="file">utils/editable.ts:1177-1204</span>)。</li>
<li>于是换行点击 = 旧行约 17 个编辑器卸载 + 新行约 17 个挂载 → <b>70~72 次节点变更、94~187ms</b>。</li>
</ol>
<p class="kv">
代码里已注明 date 必须"只挂当前格"的理由(切换格要重新挂载才能弹出日历面板,<span class="file">utils/editable.ts:1187-1201</span>)。把同一策略推广到 text / number 是降低换行成本最直接的方向,但要一并处理键盘导航、Tab 跳格与焦点恢复这几条依赖"整行已挂载"的链路,见 §4.3 的取舍分析。
</p>
<h3>3.4 根因三:页面级 errorCount 全表校验(提交卡顿)</h3>
<p>
<span class="file">web/src/views/table-test/index.vue:615-625</span> 的 <code>errorCount</code> 是
<code>computed</code>,遍历<b>全部行 × 全部列</b>,每格调用 <code>validateCell</code>(内含业务 <code>validate</code> + <code>checkPrecision</code>)。
由于 <code>rows</code> 是深层响应式 <code>ref</code>,<b>任何</b>行字段写入都会让它失效重算;而编辑提交(Enter / 失焦 / checkbox)恰恰就是写行字段。
</p>
<p>
这个 computed 的唯一消费者是工具栏那个「N 处待修正」徽章(<span class="file">index.vue:659-662</span>)。
为了一次徽章更新,每次提交都要全表校验一遍 —— <b>1000 行 31~69ms、5000 行 215~434ms</b>,是纯浪费。
另外 <code>findFirstError</code>(<span class="file">index.vue:535-551</span>)在保存时已经独立跑了一遍同样的全表校验,两者重复。
</p>
<h3>3.5 根因四:每格重复求值 + 每格分配一个 computed</h3>
<p>
<code>displayValue</code>(<span class="file">utils/editable.ts:369-454</span>)是常驻 customCell 在非编辑态的实际渲染函数,
它每次调用都会:跑业务 <code>validate</code>、跑 <code>checkPrecision</code>、跑 <code>formatDisplayValue</code>、构造 class 数组与 data 属性。
代码注释里明确写了<b>不做任何缓存</b>(<span class="file">utils/editable.ts:205-207</span>),理由是避免"数据改了红底还在"的脏状态 —— 这个取舍在语义上是对的,但代价是<b>每一帧、每一个可见格都重算一遍</b>。
</p>
<p>另外有一处纯浪费:<b>每渲染一格就分配一个 <code>computed</code></b>。</p>
<pre><code>// 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)</code></pre>
<p>
同样的写法还在 <span class="file">utils/editable.ts:717</span>(<code>createInput</code>)与
<span class="file">utils/precision.ts:114</span>(<code>createFormatCell</code>)。
按约 550 个可见格、每帧全部重渲染估算,每帧会新建<b>数百个 <code>computed</code> 实例</b>及其闭包;
Trace 里 <code>V8.GC_*</code> 事件合计约占 4% 采样,与这个分配量吻合。
</p>
<h3>3.6 根因五:固定列 sticky 与横向滚动(<span class="tag maybe">待确认</span>)</h3>
<p>横向滚动单独测的一组数字:</p>
<table>
<tr><th>配置</th><th>横向帧率</th></tr>
<tr><td>页面默认(<code>fixedColShadow=true</code>,G3Table 的 sticky 补丁生效)</td><td class="num bad">22.9 FPS</td></tr>
<tr><td>内存里把 <code>fixedColShadow</code> 置 false</td><td class="num">24.1 FPS</td></tr>
<tr><td><code>?g3-fixed-patch=off</code>(关掉 sticky 补丁)</td><td class="num good">44.4 FPS</td></tr>
<tr><td>把所有 <code>.fixed-cell</code> 强制 <code>position: static</code></td><td class="num good">49.4 FPS</td></tr>
</table>
<p>
同时抓到了固定列 class 的翻转序列:<code>fixed-cell--active fixed-cell fixed-cell--left</code>
↔ <code>fixed-cell fixed-cell--left</code>,来源是底座的 <code>updateFixedShadow</code>
(<span class="file">stk-table-vue/src/StkTable/useFixedCol.ts:92-165</span>),它按滚动位置重算
<code>fixedCols</code> → <code>fixedColClassMap</code> → 所有固定列 td 的 class 重新 patch。
</p>
<div class="note">
<b>为什么标"待确认"</b>:① 这条的开关同时改变了"呈现什么"(关掉 sticky 后固定列不再吸附,视觉上就是另一个东西),不是干净的对照;
② class 翻转只在每轮测量的<b>第一趟</b>出现(1.73 次/帧),之后归零 —— 看起来是翻越边界时的<b>瞬态</b>,而非持续逐帧;
③ 早期"内存改 prop"的那一轮可能没有真正生效(改的是 props 对象,Vue 未必响应)。
所以 22.9 → 44~49 FPS 这个差值<b>可能被高估</b>,需要用只切 <code>position</code>、保留 <code>z-index</code> 的干净 A/B 重测(见 §5)。
</div>
<p class="kv">
另外,该 sticky 补丁本身是为了修"冻结列横滚抖动"而加的(<span class="file">packages/ui/src/table/style/_overrides.scss:416-477</span>,
根因是底座只把 <code>position: sticky</code> 挂在滚动位置算出来的 <code>--active</code> 类上)。
所以"关掉补丁"不是可选修法,只能找等价但更省的实现。
</p>
<h3>3.7 根因六:次要项</h3>
<ul>
<li>
<b>无条件给 tbody 提升合成层</b>:<span class="file">G3Table.vue:1936-1940</span> 给
<code>.stk-tbody-main</code> 挂了 <code>will-change: transform</code>,注释写明是"transform 虚拟滚动模式"用的;
而底座只在 transform 模式(<code>.exp-scroll-y</code>)下才加(<span class="file">stk-table-vue/src/StkTable/style.less:392-396</span>)。
本页 <code>scrollbar=false</code>、走原生滚动,这层提升属于白付成本(库在容器上另有 <code>will-change: scroll-position</code>,<span class="file">style.less:81</span>)。
</li>
<li>
<b>29 处 <code>:has()</code> 规则</b>(<span class="file">G3Table.vue</span> 内,用于从内容节点反查 td 的编辑/校验/只读态)。
<code>:has()</code> 在样式重算时需要做子树匹配,是每帧样式重算的放大器之一;但克隆对照证明它单独不构成瓶颈,属次要项。
</li>
<li>
<b>固定列内阴影用 <code>inset 0 0 0 100vmax</code></b> 叠行状态色(<span class="file">style/_overrides.scss:497、509、540</span>)。
这是为了在不覆盖库的 <code>background-image</code> 边框的前提下叠色,代价是每格一个巨大的内阴影;同样属次要项。
</li>
</ul>
</section>
<!-- ============================================================ -->
<section>
<h2>四、修改方向</h2>
<p class="kv">按「收益 / 风险」排序。P0 = 建议先做,P1 = 值得做但需配套验证,P2 = 顺手清理。</p>
<!-- ---------------- P0-1 ---------------- -->
<div class="fix p0">
<h4><span class="tag p0">P0-1</span>去掉 errorCount 的全表重算</h4>
<p><span class="label">位置</span><span class="file">g3soft-erp/web/src/views/table-test/index.vue:615-625</span></p>
<p><span class="label">现状</span>依赖深层响应式 <code>rows</code> 的 computed,任何行字段写入都让全体「行数 × 22 列」重跑一遍校验,只为更新一个徽章。</p>
<p><span class="label">改法</span>三选一,按侵入度递增:</p>
<ul>
<li><b>(a) 最省事</b>:把 <code>computed</code> 换成普通 <code>ref</code>,只在「刷新 / 保存 / 切换数据量」时显式重算一次。徽章从"实时精确"变成"提交后更新",对演示页完全够用。</li>
<li><b>(b) 防抖</b>:保留 computed,但外面套一个 <code>watch</code>(<code>flush: 'post'</code>)+ rAF/200ms 防抖写入 <code>ref</code>,连续输入时只算最后一次。</li>
<li><b>(c) 只算可见行</b>:结合虚拟窗口,只校验视口内若干行;或维护增量计数(改哪格只重算那一格,从总数里加减)。</li>
</ul>
<p><span class="label">预期</span>1000 行每次提交省 <b>31~69ms</b>,5000 行省 <b>215~434ms</b>。</p>
<p><span class="label">风险</span>极低。它只影响一个展示徽章;<code>findFirstError</code> 在保存时仍会独立跑全表校验,提交把关不受影响。</p>
<p><span class="label">验证</span>点「保存明细」的端到端耗时(当前 1000 行 318ms / 5000 行 612ms)应显著下降。</p>
</div>
<!-- ---------------- P0-2 ---------------- -->
<div class="fix p0">
<h4><span class="tag p0">P0-2</span>阻断「首次点击 → 全表列重建」的级联</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/G3Table.vue:923-1004</span>(<code>displayColumns</code>)与 <span class="file">packages/ui/src/table/utils/column.ts:82-134</span>(<code>resolveCustomCell</code>)</p>
<p><span class="label">现状</span>全表布尔量 <code>editingActive</code> 一翻真,22 个列对象全部换新 → 底座 watch 触发几何重算 + 全表重渲染(394ms / 966 次 DOM 变更)。</p>
<p><span class="label">改法(建议按这个顺序试)</span></p>
<ul>
<li>
<b>(a) 先做归因测量,别直接改</b>:把翻转前后的 <code>displayColumns</code> 做 <code>Object.is</code> 逐列比对,确认到底有多少列<b>真的</b>需要换新对象。
目前实现是<b>无条件</b> <code>props.columns.map(...)</code>,即使某列的 <code>customCell</code> 结果与上一轮完全相同,也会产出新对象 —— 只对"结果确实变化"的列产出新对象,就已经能缩小波及面。
</li>
<li>
<b>(b) 两态记忆化</b>:为 <code>editingActive</code> 的 true / false 各缓存一份已解析的列数组,翻转时直接复用缓存,避免重复跑 22 次 <code>resolveCustomCell</code>。
注意:这<b>不能</b>消除底座 watch(数组引用仍会变),收益主要在列解析本身,属小头。
</li>
<li>
<b>(c) 真正的解法是"列不随编辑态变"</b>:让 <code>customCell</code> 的注入<b>不依赖</b> <code>editingActive</code>,改由单元格渲染函数内部读一个响应式开关来决定"渲染展示态还是编辑器"。
这样翻真相位只变<b>单元格内容</b>,<code>props.columns</code> 引用恒定,底座的 <code>handleDealColumns</code> / <code>cellStyleMap</code> 全部不触发。
<br><br>
<b>但这条有代价</b>:等于"永远注入 customCell",也就是 §2.5 测到的<b>滚动变慢 34~38%</b>。所以不能简单照做,需要配套:
<ul>
<li>给"非编辑态"写一条<b>极廉价</b>的展示分支(直接渲染纯文本,不建 span、不跑 validate / precision / 状态类),把每格的固定成本压到接近库原生 td;</li>
<li>把校验 / 精度结果做成<b>值键控缓存</b>(见 P1-1),消除重复求值。</li>
</ul>
这两条做到位之后,"永远注入但展示态极廉价"有可能<b>同时</b>赢下点击延迟和滚动帧率 —— 这是最值得投入的方向,但必须用数据验证,不能假定。
</li>
</ul>
<p><span class="label">预期</span>首次点击 394ms → 目标 &lt; 50ms((c) 若能落地,理论上可降到与"点同一格"同档的亚毫秒级)。</p>
<p><span class="label">风险</span>(c) 面较大,牵动编辑态渲染、校验态可见性(非编辑行也要能看见红底,这是刻意设计,见 <span class="file">column.ts:96-107</span>)与固定列灰底标记,需要 e2e 兜底。建议先落 (a) 拿到真实收益数据再决定是否推进 (c)。</p>
<p><span class="label">验证</span><span class="file">packages/ui/e2e/table.spec.ts</span> 已有的行内编辑用例;外加本报告 §5 的"首次点击 → 编辑器就绪"计时。</p>
</div>
<!-- ---------------- P1-1 ---------------- -->
<div class="fix p1">
<h4><span class="tag p1">P1-1</span>把每格的校验 / 精度求值改成值键控缓存</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/utils/editable.ts:369-454</span>(<code>displayValue</code> → <code>resolveEditorStatus</code> → <code>checkPrecision</code> → <code>formatDisplayValue</code>)</p>
<p><span class="label">现状</span>注释明确写了"不做任何缓存",于是每一帧、每一个可见格都要重跑一遍业务 <code>validate</code> 与精度判定。</p>
<p><span class="label">改法</span>加一层 <b>值键控</b>缓存,例如 <code>WeakMap&lt;row, Map&lt;dataIndex, { value, status }&gt;&gt;</code>,命中条件是 <code>Object.is(cachedValue, row[dataIndex])</code>。值没变就直接复用上次的 <code>{ level, message }</code>。</p>
<p><span class="label">为什么这不违背原注释的意图</span>原注释担心的是"数据改了红底还在"的<b>脏状态</b>;值键控缓存把"值"作为键,值一变缓存立即失效,语义与"每次重算"完全等价,只是省掉了值没变时的重复计算。滚动时绝大多数格的值都没变,正是收益所在。</p>
<p><span class="label">预期</span>常驻 customCell 的单帧成本显著下降;这是 §2.5 那 34~38% 里最可回收的一块。</p>
<p><span class="label">风险</span>低,但要注意:<code>validate</code> 的第 2 个参数是整行 <code>row</code>(用于跨列校验,如"目的港不能与起运港相同",<span class="file">index.vue:344-345</span>),所以缓存键不能只看本格值 —— 需要把"依赖同行其它列"的情形也纳入失效条件(保守做法:按行做版本号,行内任一字段变更即整行失效)。</p>
</div>
<!-- ---------------- P1-2 ---------------- -->
<div class="fix p1">
<h4><span class="tag p1">P1-2</span>消除每格一个 computed 的分配</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/utils/editable.ts:375</span>、<span class="file">utils/editable.ts:717</span>、<span class="file">utils/precision.ts:114</span></p>
<p><span class="label">现状</span>三处都在<b>每格渲染</b>的调用路径上执行 <code>const { e, is } = useNamespace('table')</code>,而 <code>useNamespace</code> 内部会 <code>computed(...)</code>(<span class="file">_composables/useNamespace.ts:46</span>)并创建若干闭包。按约 550 个可见格 × 每帧重渲染估算,每帧新建数百个 <code>computed</code> 实例。</p>
<p><span class="label">改法</span>把命名空间取值提到<b>模块级或列解析期</b>(<code>resolveCustomCell</code> 时算一次,闭包捕获),单元格渲染函数只读结果。命名空间在同一棵树上不会逐格变化,按格重新解析没有意义。</p>
<p><span class="label">预期</span>降低 GC 压力(Trace 中 <code>V8.GC_*</code> 约占 4% 采样)与每格的固定开销。</p>
<p><span class="label">风险</span>低。唯一要留意的是 <code>useNamespace</code> 会在 dev 下做一次命名空间一致性 <code>console.warn</code>(<span class="file">useNamespace.ts:52-60</span>),提到模块级后该警告的触发次数会变,但不影响功能。</p>
</div>
<!-- ---------------- P1-3 ---------------- -->
<div class="fix p1">
<h4><span class="tag p1">P1-3</span>评估"只给当前格挂编辑器"能否推广到 text / number</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/utils/editable.ts:1177-1204</span>(<code>createEditableCell</code>)</p>
<p><span class="label">现状</span>只有 <code>date</code> 是"仅当前格挂编辑器",text / number 在行编辑态下整行挂载 → 换行点击 70 次 DOM 变更 / 94~187ms。</p>
<p><span class="label">改法</span>把 date 的策略推广到 text / number:只有 <code>ctx.shouldFocus(row, col)</code> 为真的那一格挂编辑器,其余格走 <code>displayValue</code>。</p>
<p><span class="label">为什么值得试</span>换行点击的 DOM 变更量可望从约 70 降到个位数(对齐"点同一格 0.6ms"的量级)。</p>
<p><span class="label">风险(需要认真评估)</span>代码里已写明 date 之所以特殊,是因为"切换格时新格是<b>挂载</b>,<code>onVnodeMounted</code> 才会发生"(<span class="file">editable.ts:1187-1201</span>)。
整行挂载很可能被键盘导航、Tab 跳格、焦点恢复、Enter 下移这几条链路隐式依赖(它们可能假定目标格已有编辑器实例)。改动前应先用 e2e 跑通这些路径,或先做实验性开关对照。</p>
<p><span class="label">验证</span><span class="file">packages/ui/e2e/table.spec.ts</span> 的键盘导航 / Tab 用例,以及"进入编辑行高不变"这类几何用例。</p>
</div>
<!-- ---------------- P1-4 ---------------- -->
<div class="fix p1">
<h4><span class="tag p1">P1-4</span>用干净 A/B 确认固定列 sticky 的横向成本</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/style/_overrides.scss:416-477</span>(sticky 补丁)、<span class="file">stk-table-vue/src/StkTable/useFixedCol.ts:92-165</span>(<code>updateFixedShadow</code>)</p>
<p><span class="label">现状</span>横向 22.9 FPS;关掉补丁 44.4 FPS;全部 static 49.4 FPS。但该对照不干净(关掉 sticky 等于关掉固定列功能)。</p>
<p><span class="label">改法(先测量,后决定)</span></p>
<ul>
<li>只切 <code>position</code>、保留 <code>z-index</code>,其余 CSS 完全不动,交替多轮取中位;</li>
<li>用 CDP 计数 <code>RecalcStyleCount</code> / <code>LayoutCount</code> / <code>Paint</code> 定位成本落在哪一层;</li>
<li>若确认 sticky 是主因,考虑:把 <code>updateFixedShadow</code> 的 class 翻转收敛为只在跨越边界时发生(<span class="file">useFixedCol.ts:136-152</span> 的判据用的是滚动位置,本就是边界敏感的),或改用 CSS 变量 / <code>:nth-child</code> 表达阴影,避免整列 td 的 class 反复 patch。</li>
</ul>
<p><span class="label">风险</span>中。sticky 补丁是为修"冻结列横滚抖动"而存在的真实修复,任何替换都必须先复现并验证那个抖动不再出现。</p>
</div>
<!-- ---------------- P2 ---------------- -->
<div class="fix p2">
<h4><span class="tag p2">P2-1</span>让 <code>will-change: transform</code> 只在 transform 滚动模式下生效</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/G3Table.vue:1936-1940</span></p>
<p><span class="label">现状</span>无条件给约 28000px 高的 <code>.stk-tbody-main</code> 挂 <code>will-change: transform</code>,但注释自己写明这是为 "transform 虚拟滚动模式"准备的;本页走原生滚动(<code>scrollbar: false</code>),库也只在该模式下才加这条(<span class="file">stk-table-vue/src/StkTable/style.less:392-396</span>)。</p>
<p><span class="label">改法</span>把这条规则限定到实验性滚动模式(库的 <code>.exp-scroll-y</code>)之下,与底座行为对齐。</p>
<p><span class="label">预期</span>收益不大(属次要项),但能省掉一个 28000px 高的大合成层。</p>
<p><span class="label">风险</span>低;需确认没有别的地方依赖这层提升(例如某些浏览器的平滑度)。</p>
</div>
<div class="fix p2">
<h4><span class="tag p2">P2-2</span>注意 100 行这个"两头不讨好"的区间</h4>
<p><span class="label">位置</span><span class="file">packages/ui/src/table/G3Table.vue:251</span>(<code>MIN_VIRTUAL_ROW_COUNT = 100</code>)</p>
<p><span class="label">现状</span>门控是 <code>&gt; 100</code> 才开虚拟滚动;100 行正好落在<b>关闭</b>一侧,全量渲染 2300 个 td。实测纵向 58.8 FPS(可接受),但<b>横向只有 7.8 FPS</b> —— 本报告测到的最差横向值。</p>
<p><span class="label">改法</span>这不一定要改门控(门控是为了"行数略超一屏时最后几行被吞掉"的确定性渲染,见同处注释)。可选:把阈值下调,或在有固定列且总列宽溢出时改用更保守的门控。建议先记录为已知边界。</p>
<p><span class="label">风险</span>改门控会影响渲染确定性,需重跑"行数 100~110 时最后一行可见"这类用例。</p>
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>五、如何验证(可复现)</h2>
<p>本次排查新增了一批只读探针脚本,都放在 <span class="file">g3soft-libs/packages/ui/</span> 下,需要时可直接重跑(前置:后端 8088、前端 dev server 5090 已启动):</p>
<table>
<tr><th>脚本</th><th>作用</th></tr>
<tr><td><span class="file">probe-table-perf.mjs</span></td><td>总入口:DOM 几何、纵/横向帧率、点击延迟、CPU profile</td></tr>
<tr><td><span class="file">probe-table-trace.mjs</span> / <span class="file">probe-table-loaf.mjs</span></td><td>用 CDP Trace / Long Animation Frames 做逐帧归因</td></tr>
<tr><td><span class="file">probe-table-decisive.mjs</span></td><td><b>冻结克隆对照</b>(本报告最关键的证据)</td></tr>
<tr><td><span class="file">probe-table-gpu.mjs</span></td><td>headless / 强制 GPU / headed 三种渲染路径对照</td></tr>
<tr><td><span class="file">probe-table-final.mjs</span></td><td>editor 增删 A/B、点击分场景、errorCount 与保存耗时</td></tr>
<tr><td><span class="file">probe-table-churn.mjs</span></td><td>横向滚动 class 抖动与 sticky 对照</td></tr>
<tr><td><span class="file">probe-table-prod.mjs</span></td><td>生产构建对照(未完成,见 §6)</td></tr>
</table>
<p>建议的回归验证口径(修完后对照本报告基线的数值):</p>
<ol>
<li>静止 61 FPS、纵向 12~18 FPS、横向 18~32 FPS、编辑态 8~11 FPS;</li>
<li>首次点击 394ms / 966 次 DOM 变更;换行点击 94~187ms;同格点击 0.6ms;</li>
<li>errorCount 重算 1000 行 31~69ms、5000 行 215~434ms;保存端到端 318ms / 612ms;</li>
<li>摘掉 editor 的对照值 25.2 FPS(纵向)/ 43.3 FPS(横向)—— 修完后应显著<b>接近</b>这组数,而不是被它反超。</li>
</ol>
<p class="kv">
提示:直接访问 <code>/table-test</code> 会因缺 <code>menu.table_test</code> 授权被送到 <code>/403</code>。
要么用探针脚本里的权限响应拦截,要么给 <code>demo_zhangwei</code> 补一条授权(<span class="file">sql/20_registration/13_table_test_menu.sql</span>)。
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>六、本次没做的事 / 已知不确定</h2>
<ul>
<li><b>没有改动任何业务代码或组件库代码</b>。所有实验都在浏览器内存里或测试进程内完成(例如临时增删列上的 <code>editor</code>、注入 <code>&lt;style&gt;</code> 覆盖 CSS、拦截 API 响应补权限),仓库源码为零改动。</li>
<li><b>生产构建 vs dev 模式的对照没做完</b>。构建已成功(<span class="file">vite build</span>),但在准备用 preview 测量时中止。预计 dev 模式(未压缩、无 tree-shaking)会比生产更慢,但这<b>不足以</b>解释 3~4 倍差距 —— 冻结克隆实验已经在同一 dev 环境下拿到 60 FPS。若需要精确数字,可重跑 <span class="file">probe-table-prod.mjs</span>。</li>
<li><b>横向滚动与固定列 sticky 的因果未完全坐实</b>(§3.6)。22.9 → 44~49 FPS 的差值很可能是真的,但对干净度不够,建议按 P1-4 重测后再据此决策。</li>
<li><b>单帧内 JS / 样式 / 布局 / 绘制的精确占比没有定论</b>。CDP <code>Performance</code> 计数器、DevTools Trace 叶子事件汇总、LoAF 三种口径互相不完全吻合;LoAF 把可归因的脚本时间几乎全部指向底座一个 rAF 回调(<span class="file">StkTable.js</span>,<code>sourceCharPosition≈90337</code>),由于是打包产物、函数名为空,该偏移只能作为线索,不能当作精确行号。本报告因此只使用那些"方向明确、且被对照实验支撑"的结论。</li>
<li><b>没有验证"优化后"的实际收益</b>。§4 给出的是方向与预期量级,其中 P0-1 的量级最确定(直接对应实测的 31~434ms),P0-2 的 (c) 方案需要先做实验才能下结论。</li>
</ul>
</section>
<footer>
/table-test 表格性能报告与修改方向 · 2026-10-16 · 依据为真实浏览器(headed + NVIDIA GTX 1050)实测,源码零改动
</footer>
</div>
</body>
</html>