Files
workspace/code/g3soft-erp/web/table-test-性能排查报告.html
T
2026-10-16 17:30:17 +08:00

339 lines
21 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;
--danger: #ec4246;
--warning: #e79a0d;
--ok: #00b42a;
--code-bg: #f2f3f5;
}
* { 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.75;
}
.page { max-width: 920px; 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 8px; font-size: 22px; }
header.doc .meta { color: var(--text-3); font-size: 13px; }
header.doc .meta span { margin-right: 18px; }
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 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
h3 { font-size: 15px; margin: 22px 0 8px; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0; padding-left: 24px; }
li { margin: 4px 0; }
code {
background: var(--code-bg);
border-radius: 4px;
padding: 1px 6px;
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 12.5px;
color: #c0341d;
word-break: break-all;
}
.file { color: var(--primary); }
table {
width: 100%;
border-collapse: collapse;
margin: 12px 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; white-space: nowrap; }
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: #f0f5ff;
border: 1px solid #bedaff;
border-radius: 8px;
padding: 14px 18px;
margin: 12px 0;
}
.tldr ol { margin: 6px 0 0; }
.note {
background: #fff7e8;
border: 1px solid #f5d8a8;
border-radius: 8px;
padding: 12px 16px;
margin: 12px 0;
font-size: 13px;
color: #6b5300;
}
.chain {
counter-reset: step;
list-style: none;
padding-left: 0;
margin: 12px 0;
}
.chain > li {
counter-increment: step;
position: relative;
padding-left: 34px;
margin: 10px 0;
}
.chain > li::before {
content: counter(step);
position: absolute;
left: 0; top: 2px;
width: 22px; height: 22px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 12px;
line-height: 22px;
text-align: center;
font-weight: 600;
}
.fix {
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: 8px;
padding: 14px 18px;
margin: 12px 0;
}
.fix.p1 { border-left-color: var(--danger); }
.fix.p2 { border-left-color: var(--warning); }
.fix h4 { margin: 0 0 6px; font-size: 14.5px; }
.fix .tag {
display: inline-block;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
padding: 0 8px;
margin-right: 8px;
vertical-align: 1px;
}
.tag.high { background: #ffece8; color: var(--danger); }
.tag.mid { background: #fff7e8; color: var(--warning); }
.tag.low { background: #e8ffea; color: var(--ok); }
.fix p { margin: 6px 0; color: var(--text-2); font-size: 13.5px; }
.kv { color: var(--text-3); font-size: 12.5px; }
footer { color: var(--text-3); font-size: 12.5px; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<div class="page">
<header class="doc">
<h1>/table-test 表格性能排查报告</h1>
<div class="meta">
<span>日期:2026-10-16</span>
<span>对象:<code>web/src/views/table-test/index.vue</code> + <code>@g3soft/ui</code> G3Table(底座 stk-table-vue 1.2.7)</span>
<span>状态:已定位根因,未改代码</span>
</div>
</header>
<section>
<h2>摘要(TL;DR)</h2>
<div class="tldr">
<ol>
<li><b>滚动卡顿的根因</b>:数据量 &gt; 100 行时纵向虚拟滚动开启,每滚一步都触发<b>整个表格组件重渲染</b>(可见约 31 行 × 23 列 ≈ 713 个单元格重算 + 行窗口 DOM 进出),CPU profile 显示 67% 时间消耗在浏览器端样式重算/布局/绘制。同为 1000 行关掉虚拟滚动(全量渲染 23000 个 td)反而满帧 60fps —— 卡的是"每帧重渲染",不是表格大。</li>
<li><b>点击卡顿的根因</b>:首次点击使 <code>editingActive</code> 翻真,触发<b>全量列重建</b>(23 个新列对象 → 列宽/样式缓存重建 → 全表重排重绘),实测主线程阻塞约 400~550ms;后续点击约 130~200ms。</li>
<li><b>点击后滚动会更卡</b>(44ms → 75ms/帧):编辑列全部注入 customCell 每格跑校验;且底座区域选择的 watcher 依赖滚动窗口索引,每次滚动都全量遍历可见格更新选区 DOM。</li>
<li><b>页面自身还有一处</b>:<code>errorCount</code> computed 每次提交都全量重跑「行数 × 22 列」校验,1000 行多付 ~60ms、5000 行多付 ~190ms,20000 行约 +0.8s/次。</li>
<li>横向滚动基本正常(p50 16.7ms),问题集中在纵向滚动与点击编辑。</li>
</ol>
</div>
</section>
<section>
<h2>一、测试方法</h2>
<ul>
<li><code>demo_zhangwei</code> 没有 <code>/table-test</code> 菜单权限(访问会被守卫送到 <code>/403</code>),因此在 Vite dev server 下搭建了<b>复刻探针页</b>:同样的 22 列定义、<code>rowHeight=28</code>、1000 行同分布假数据、同样的 <code>fixed-col-shadow</code> 与校验规则,排除 ERP 外壳干扰。</li>
<li>测量手段:Playwright 驱动真实鼠标/滚轮;页内 <code>requestAnimationFrame</code> 采样帧间隔;<code>PerformanceObserver</code> 抓 longtask;CDP <code>Profiler</code> 抓 CPU 热点(自底向上 self time 聚合)。</li>
<li>对照组:裸 <code>div</code> 5000 行滚动(浏览器基线,满帧 60fps,验证测法可靠);纯展示列表格;完整编辑配置表格;virtual on/off 对比。</li>
</ul>
<div class="note">
<b>数值口径说明</b>:headless Chromium 为软件渲染(无 GPU 合成),绝对帧时间比实机差;但相对倍数、瓶颈归属(浏览器端 vs JS)、longtask 分布在真实环境同样成立。文中所有结论均经「开/关某一因素」的对照实验确认,非单纯推断。
</div>
</section>
<section>
<h2>二、症状一:滚动 FPS 特别低</h2>
<h3>2.1 实测数据(1000 行,视口 1440×900,容器高约 866px)</h3>
<table>
<tr><th>场景</th><th>平均帧</th><th>p50</th><th>p95</th><th>卡顿帧(&gt;32ms)</th></tr>
<tr><td>纵向滚动,virtual <b>on</b>(页面默认 1000 行)</td><td class="num bad">34~75ms(13~29 FPS)</td><td class="num bad">33~100ms</td><td class="num bad">83~167ms</td><td class="num">30~41 / 约 60 帧</td></tr>
<tr><td>纵向滚动,点击过单元格后(选区常驻)</td><td class="num bad">74.8ms(13 FPS)</td><td class="num bad">99.9ms</td><td class="num bad">166.7ms</td><td class="num">19 / 35</td></tr>
<tr><td>横向滚动(fixed-col-shadow 开)</td><td class="num">19.9ms</td><td class="num good">16.7ms</td><td class="num">33.4ms</td><td class="num">22</td></tr>
<tr><td>同样 1000 行,virtual <b>off</b>(全量 23000 td,滚动零重渲染)</td><td class="num">28ms</td><td class="num good">16.7ms</td><td class="num">116.6ms</td><td class="num">23</td></tr>
<tr><td>100 行(门控自动关闭虚拟)</td><td class="num good">16.7ms(60 FPS)</td><td class="num good">16.7ms</td><td class="num good">16.7ms</td><td class="num good">0</td></tr>
<tr><td>裸 div 5000 行(浏览器基线)</td><td class="num good">16.7ms(60 FPS)</td><td class="num good">16.7ms</td><td class="num good">16.8ms</td><td class="num good">0</td></tr>
</table>
<h3>2.2 根因链</h3>
<ol class="chain">
<li>G3Table 的纵向虚拟门控为「行数 &gt; 100 开启」(<span class="file">g3soft-libs/packages/ui/src/table/G3Table.vue:251</span>),页面默认 1000 行 → 开启。</li>
<li>滚轮/滚动条每动一次 → <code>onTableScroll</code>(rAF 节流)→ <code>updateVirtualScrollY</code> 换窗 → <code>triggerRef(virtualScroll)</code> → <b>整个 StkTable 组件重渲染</b>:表头 23 个 th + 可见 31 行 × 23 列 ≈ <b>713 个单元格的 props 重算与 diff</b>,外加 2 行 DOM 进出。</li>
<li>CPU profile(滚动 2.5s):<b>67% 时间在浏览器端 <code>(program)</code></b>(样式重算/布局/绘制),Vue JS 仅 ~7%,GC 2.9%,<code>setProperty</code>(Vue 写 style)4%。即贵在每帧 713 格的 style/layout/paint。</li>
<li>浏览器端成本被以下 CSS 放大:
<ul>
<li><code>.g3-table__inner .stk-tbody-main { will-change: transform }</code>(<span class="file">G3Table.vue:1936</span>)——<b>无条件</b>给约 28000px 高的 tbody 挂合成层;库自己只在 transform 滚动模式(<code>.exp-scroll-y</code>)下才加(<span class="file">stk-table-vue/src/StkTable/style.less:392</span>),本页走原生滚动,这层纯属白付(实测去掉仅小幅改善,属次要因素)。</li>
<li>4 个冻结列 × 31 行 ≈ 124 个 <code>position: sticky</code> 格(补丁让所有 fixed-cell 恒常 sticky,<span class="file">style/_overrides.scss:460</span>)。</li>
<li>29 条 <code>td:has(...)</code> 规则(编辑器/校验/只读态反查)+ 库用背景渐变画格线。</li>
</ul>
</li>
<li><b>判定性对照</b>:同样 1000 行显式 <code>virtual=false</code>(一次渲染 23000 个 td)→ 滚动 p50 回到 16.7ms 满帧。证明卡的是「虚拟滚动开启后的每帧重渲染」,而非 DOM 总量。</li>
</ol>
<h3>2.3 为什么点击后滚动更卡(44ms → 75ms/帧)</h3>
<ol class="chain">
<li><code>editingActive</code> 翻真后,注入 customCell 的列从 10 个(select/checkbox/validate/precision 常驻)涨到 <b>17 个</b>(text/number/date 全部加入,<span class="file">utils/column.ts:121-134</span>),每格每帧走 <code>displayValue</code>:跑业务 <code>validate</code> + <code>checkPrecision</code> + 日期校验 + 数字格式化(<span class="file">utils/editable.ts:369-454</span>)。</li>
<li>底座区域选择的 watcher(<span class="file">stk-table-vue/src/StkTable/features/useAreaSelection.ts:319-342</span>)<b>依赖 <code>vs.startIndex/endIndex</code></b> → 滚动换窗即触发 <code>updateSelectionDOM</code>:先 <code>querySelectorAll</code> 清旧选区,再逐行 <code>querySelectorAll('td[data-col-key]')</code> 遍历全部可见格、逐格生成 cellKey 并查 Set。选区在点击后<b>常驻</b>(Esc、点表格外都不清除,实测点击后选区数仍为 1),此后每次滚动都付这笔钱。</li>
</ol>
</section>
<section>
<h2>三、症状二:点击单元格卡顿</h2>
<h3>3.1 实测 longtask(主线程被阻塞时长)</h3>
<table>
<tr><th>操作</th><th>longtask 序列</th><th>随后最大帧</th><th>估算阻塞</th></tr>
<tr><td>首次点击某可编辑格</td><td class="num">137ms → <b>389ms</b></td><td class="num bad">416.7ms</td><td class="num bad">~400~550ms</td></tr>
<tr><td>点另一行同列</td><td class="num">92ms → 136ms</td><td class="num">166.7ms</td><td class="num">~230~300ms</td></tr>
<tr><td>行内换到另一列</td><td class="num">130ms → 121ms</td><td class="num">133.3ms</td><td class="num">~250ms</td></tr>
<tr><td>点只读列(订舱号)</td><td class="num">97ms → 198ms</td><td class="num">216.6ms</td><td class="num">~300ms</td></tr>
</table>
<p class="kv">编辑器聚焦本身很快(mousedown 后 ~0.3ms 即聚焦),卡的是点击引发的重渲染/重排链条。</p>
<h3>3.2 根因链</h3>
<ol class="chain">
<li>mousedown → 底座选区变化 → <code>handleAreaSelectionChange</code> → <code>activateEditing</code> → <code>editingActive</code> 翻真(<span class="file">G3Table.vue:584-609</span>)。</li>
<li><code>displayColumns</code> <b>全量重建</b>:23 个列全部 map 成新对象(<span class="file">G3Table.vue:923-1004</span>)。</li>
<li>StkTable <code>props.columns</code> 引用变化 → watch → <code>handleDealColumns</code> + <code>updateMaxRowSpan</code> → nextTick → <code>initVirtualScrollX</code> + <code>updateFixedShadow</code> + <code>updateCustomScrollbar</code>(<span class="file">StkTable.vue:1240-1252</span>)。</li>
<li><code>tableHeaders</code> 更换 → <code>cellStyleMap</code> 重建 → <b>全表重渲染</b> + 编辑器挂载 + <code>focus()</code>。CPU profile:主要成本仍在浏览器端(364ms <code>(program)</code>,即全表 style/layout/paint);<code>focus()</code> 本身占 40~57ms。</li>
<li>后续点击不重建列(<code>editingActive</code> 已为真),只剩行编辑态切换 + 选区 DOM 更新 + <code>setCurrentRow</code>,故成本降至 ~130~200ms。</li>
</ol>
</section>
<section>
<h2>四、症状三(顺带发现):页面自身的 errorCount 放大编辑提交卡顿</h2>
<p><code>views/table-test/index.vue:615-625</code> 的 <code>errorCount</code> 是 computed,依赖整个深层响应式 <code>rows</code>,<b>每次提交(Enter / 失焦 / checkbox)都全量重跑「行数 × 22 列」校验</b>(每格还要跑 <code>validate</code> + <code>checkPrecision</code>)。实测 Enter 提交耗时:</p>
<table>
<tr><th>数据量</th><th>errorCount 开</th><th>errorCount 关</th><th>差值</th></tr>
<tr><td>1000 行</td><td class="num">139ms</td><td class="num">79ms</td><td class="num warn">+60ms</td></tr>
<tr><td>5000 行</td><td class="num bad">278ms</td><td class="num">85ms</td><td class="num bad">+193ms</td></tr>
</table>
<p>按线性外推,选 20000 行时每次提交约多付 <b>~0.8s</b>。这是页面级问题,与组件库无关;工具栏徽章只在打开页面/保存时有意义,不必随每次编辑重算。</p>
</section>
<section>
<h2>五、修改方向(按性价比排序,本次未实施)</h2>
<div class="fix p1">
<h4><span class="tag high">P1 · 根子</span> 虚拟滚动开启时避免整表重渲染</h4>
<p><b>位置</b>:<code>stk-table-vue</code>(node_modules,属上游库)或 <code>G3Table.vue</code> 的 tbody 渲染组织方式。</p>
<p><b>问题</b>:<code>triggerRef(virtualScroll)</code> 一触发,整个 StkTable 单组件重渲染,713 个单元格 props 全量重算与 diff。</p>
<p><b>方向</b>:把 tbody 行拆成独立子组件(每行一个渲染作用域),行数据不变时跳过该行 patch;或对可见窗口做更细的 keyed diff 复用。virtual off 满帧已证明 DOM 量不是问题,问题是每帧全量重算。</p>
</div>
<div class="fix p2">
<h4><span class="tag mid">P2</span> 去掉无条件的 will-change: transform</h4>
<p><b>位置</b>:<code>G3Table.vue:1932-1941</code>(SCSS 块,非 scoped)。</p>
<p><b>问题</b>:原生滚动模式下也给 28000px 高的 tbody 常驻合成层;库自己仅在 <code>.exp-scroll-y</code> 下才加(<code>style.less:392-396</code>)。</p>
<p><b>方向</b>:与该条件对齐,仅在 transform 滚动模式输出此规则;或直接删除(transform 模式由库自己加)。实测单独去除改善有限(41→38ms),属低成本顺手项。</p>
</div>
<div class="fix p2">
<h4><span class="tag mid">P2</span> updateSelectionDOM 不应随滚动窗口全量跑</h4>
<p><b>位置</b>:<code>stk-table-vue/src/StkTable/features/useAreaSelection.ts:319-342</code>。</p>
<p><b>问题</b>:watcher 依赖 <code>startIndex/endIndex</code>,滚动换窗即对全部可见格做 DOM 遍历 + cellKey 生成;选区常驻导致每次滚动都付。</p>
<p><b>方向</b>:选区为空时早退;把「逐格生成 cellKey 查 Set」改为按 <code>selectedCellKeys</code> 直接定位 <code>td[data-col-key]</code> 批量 setAttribute;或只在选区真正变化时重算,滚动换窗时按需增量更新。</p>
</div>
<div class="fix p2">
<h4><span class="tag mid">P2</span> 点击时不做全量列重建</h4>
<p><b>位置</b>:<code>G3Table.vue:923</code>(<code>displayColumns</code> computed 依赖 <code>editingActive</code>)。</p>
<p><b>问题</b>:首次点击 <code>editingActive</code> 翻真 → 23 个列对象全部重建 → 列宽/样式缓存重建 → 全表重排重绘(~400ms)。</p>
<p><b>方向</b>:编辑态从「全表列级」下沉为「行级」——只给目标行/目标列注入编辑器(customCell 内部已有 <code>ctx.isEditing(row)</code> 判断,具备按行分发的基礎);或缓存列对象、仅对受影响的列生成新引用,避免 <code>tableHeaders</code>/<code>cellStyleMap</code> 整体失效。</p>
</div>
<div class="fix p1">
<h4><span class="tag high">P1 · 零风险</span> errorCount 从 computed 改为按需计算</h4>
<p><b>位置</b>:<code>web/src/views/table-test/index.vue:615-625</code>。</p>
<p><b>问题</b>:每次提交全量重跑 行数×22 校验,1000 行 +60ms、5000 行 +193ms、20000 行约 +0.8s。</p>
<p><b>方向</b>:改为在 <code>saveEdit()</code> 里按需算一次(保存时本就要全表校验,可与 <code>findFirstError</code> 合并成一次遍历);或只统计可见行 + 防抖。徽标语义不变。</p>
</div>
<div class="fix p2">
<h4><span class="tag mid">P2</span> 评估每格校验的缓存</h4>
<p><b>位置</b>:<code>utils/editable.ts:369-454</code>(<code>displayValue</code> → <code>resolveEditorStatus</code>)。</p>
<p><b>问题</b>:滚动时每格每帧重跑 <code>validate</code> + <code>checkPrecision</code> + 日期校验 + 格式化;点击后常驻 customCell 从 10 列涨到 17 列,成本随之上涨。</p>
<p><b>方向</b>:校验结果是行数据的派生值,可按 (row, dataIndex, value) 做 WeakMap 缓存,值不变则复用;<code>useNamespace</code> 每格渲染都新建 computed(<code>_composables/useNamespace.ts:41</code>),可下沉为模块级单例。</p>
</div>
<div class="fix p2">
<h4><span class="tag low">P3 · 设计取舍</span> 虚拟滚动门控阈值</h4>
<p><b>位置</b>:<code>G3Table.vue:251</code>(<code>MIN_VIRTUAL_ROW_COUNT = 100</code>)。</p>
<p><b>说明</b>:阈值本身合理(小表示全量渲染更稳),不建议为性能调高——那只是把问题藏到「DOM 全量」里,万行级内存与首渲染会更差。应优先修 P1,让虚拟滚动本身变快。</p>
</div>
</section>
<section>
<h2>附录:关键代码位置索引</h2>
<table>
<tr><th>文件</th><th>行</th><th>内容</th></tr>
<tr><td><code>g3soft-libs/packages/ui/src/table/G3Table.vue</code></td><td class="num">251</td><td>纵向虚拟门控 <code>MIN_VIRTUAL_ROW_COUNT = 100</code></td></tr>
<tr><td></td><td class="num">584</td><td><code>activateEditing</code> / <code>editingActive</code> 翻转</td></tr>
<tr><td></td><td class="num">923</td><td><code>displayColumns</code> 全量重建(点击卡顿入口)</td></tr>
<tr><td></td><td class="num">1207</td><td><code>handleAreaSelectionChange</code>(点击 → 进编辑链路)</td></tr>
<tr><td></td><td class="num">1932</td><td>tbody 无条件 <code>will-change: transform</code></td></tr>
<tr><td><code>g3soft-libs/packages/ui/src/table/utils/column.ts</code></td><td class="num">121</td><td>customCell 注入判据(editingActive 后 10 → 17 列)</td></tr>
<tr><td><code>g3soft-libs/packages/ui/src/table/utils/editable.ts</code></td><td class="num">369</td><td><code>displayValue</code>:展示态每格跑校验</td></tr>
<tr><td><code>g3soft-libs/packages/ui/src/table/style/_overrides.scss</code></td><td class="num">460</td><td>fixed-cell 恒常 sticky 补丁</td></tr>
<tr><td><code>stk-table-vue/src/StkTable/StkTable.vue</code></td><td class="num">1240</td><td>columns 变化 → 全量重算链</td></tr>
<tr><td><code>stk-table-vue/src/StkTable/StkTable.vue</code></td><td class="num">1927</td><td><code>onTableScroll</code>(rAF 节流的滚动入口)</td></tr>
<tr><td><code>stk-table-vue/src/StkTable/features/useAreaSelection.ts</code></td><td class="num">319</td><td>选区 watcher 依赖 startIndex/endIndex</td></tr>
<tr><td><code>stk-table-vue/src/StkTable/features/useAreaSelection.ts</code></td><td class="num">217</td><td><code>updateSelectionDOM</code> 全可见格遍历</td></tr>
<tr><td><code>web/src/views/table-test/index.vue</code></td><td class="num">615</td><td><code>errorCount</code> computed(页面级提交卡顿)</td></tr>
</table>
</section>
<footer>排查基于 2026-10-16 dev server 实测;探针页面与脚本已清理,未改动任何仓库文件。</footer>
</div>
</body>
</html>