693 lines
49 KiB
HTML
693 lines
49 KiB
HTML
<!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(>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>>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>>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 → 目标 < 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<row, Map<dataIndex, { value, status }>></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>> 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><style></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>
|