cloneNode(true) 出来放进同样尺寸的容器里滚动 → 60 FPS 满帧;同一份 DOM 由 Vue 管着滚 → 11~18 FPS。CSS(sticky / 渐变格线 / box-shadow / :has())全被这一条排除。editingActive 由 false 翻 true → displayColumns 整体重建为 22 个新列对象 → 底座 watch 触发 handleDealColumns() + initVirtualScrollX() + updateFixedShadow() → 实测 394ms / 966 次 DOM 变更。errorCount 是依赖深层响应式的 computed,每次写行字段都重跑「行数 × 22 列」全表校验:1000 行 31~69ms、5000 行 215~434ms,随行数线性恶化。table-test-性能排查报告.html(其他 AI 产出的,按用户说明不必参考)。它把滚动瓶颈归因为"浏览器端 style/layout/paint 占 67%,Vue JS 仅 7%",并据此建议优化 CSS。
本报告的冻结克隆对照给出了更直接的判据:同一份 DOM、同样的 CSS,只要不重渲染就是 60 FPS。也就是说那 67% 的浏览器端开销是被每帧重渲染触发出来的结果,不是独立原因 —— 优化 CSS 收益有限,减少"每帧被改动的单元格数"才是主线。两者在"逐帧重渲染是问题"这一点上其实一致,分歧只在归因层级。
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(侧栏 / 顶栏 / 页签)外壳,而非隔离环境。
低帧率最容易被怀疑成 headless 无 GPU 的软件光栅化。实测三种渲染路径:
| 渲染路径 | WebGL Renderer | 纵向滚动 | 横向滚动 |
|---|---|---|---|
| headless(默认) | ANGLE / SwiftShader(软件) | 13.4 / 11.6 FPS | 17.9 FPS |
| headless + 强制 D3D11 | ANGLE / NVIDIA GTX 1050 | 9.2 / 11.1 FPS | 17.8 FPS |
| headed(真实窗口) | ANGLE / NVIDIA GTX 1050 | 13.9 / 14.9 FPS | 28.9 FPS |
把现场 .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 倍帧率。这也就是本报告把主线定在"减少每帧重渲染"的依据。
| 结论 | 依据 | 可信度 |
|---|---|---|
| 滚动掉帧、点击卡顿真实存在 | 真实页面 + 真实 GPU + 帧间隔采样 | 高 |
| 瓶颈是每帧重渲染,而非 CSS/绘制 | 冻结克隆对照(60 FPS vs 11~18 FPS) | 高 |
| 首次点击 394ms 源于 editingActive→列重建 | 代码链路 + DOM 变更计数 + 分场景计时 | 高 |
| errorCount 是 O(行×列) 且随行数线性恶化 | 1000/5000 行直接计时,5 组取值 | 高 |
| 常驻 customCell 使帧率下降 34~38% | 交替 3 轮 A/B 取中位 | 高 |
| 横向滚动的主要成本是固定列 sticky | 22.9 → 44.4 → 49.4 FPS,但开关同时改变了别的东西 | 待确认 |
| 单帧内 JS / 样式 / 布局 / 绘制的精确占比 | CDP Trace 与 LoAF 归因互相不完全吻合,存在测量口径问题 | 待确认 |
| 场景 | 中位帧率 | p50 帧 | p95 帧 | 最差帧 |
|---|---|---|---|---|
| 静止(不滚动) | 61 FPS | 16.7ms | 18.0ms | 18.3ms |
| 纵向滚动 | 9~18 FPS 多数落在 11~15 | 63~74ms | 115~143ms | up to 500ms |
| 横向滚动 | 10~32 FPS 多数落在 20~26 | 37~49ms | 61~86ms | 120~4800ms |
| 编辑态下纵向滚动 | 8~11 FPS | 106ms | 249ms | 502ms |
滚动期间实测 long task(>50ms)42 个,平均 77.8ms,最大 533ms —— 主线程被成片占满,这就是"FPS 特别低"的直接形态。
用页面自带的「数据量」下拉切换后测量:
| 数据量 | 虚拟滚动 | 渲染出的 DOM | 每帧 DOM 变更 | 纵向 | 横向 |
|---|---|---|---|---|---|
| 10 行 | 关 | 10 tr / 230 td | 0 节点 / 0 属性 | 60.7 FPS | 39.7 FPS |
| 100 行 | 关(>100 才开) | 100 tr / 2300 td | 0 / 0 | 58.8 FPS | 7.8 FPS |
| 1000 行 | 开 | 26 tr / 552 td | 12.8 / 1.9 | 11.7 FPS | 21.8 FPS |
| 5000 行 | 开 | 26 tr / 552 td | 12.8 / 1.9 | 11.3 FPS | 21.5 FPS |
>100 才开(G3Table.vue:251),所以 100 行会全量渲染 2300 个 td;纵向还行,横向只有 7.8 FPS(全表 2300 个 td 里含 400 个 sticky 固定列格,横向平移要整片重绘)。这是本报告见到的横向最差值。| 操作 | 点击 → 编辑器进 DOM | tbody 节点增删 | 属性写入 | 说明 |
|---|---|---|---|---|
| 页面加载后首次点任意格 | 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 | 只换焦点 |
| 指标 | 1000 行 | 5000 行 |
|---|---|---|
| 写一个行字段(响应式写入本身) | 0.0~0.1ms | 0.0~0.1ms |
errorCount 重算 | 31.3 / 32.1 / 34.1ms(另测 68.7ms) | 215.1 / 434.2ms |
| 失焦提交端到端(含错误格定位渲染) | 286.1ms | 465.3ms |
| 点「保存明细」端到端 | 318.1ms | 612.3ms |
| 直接跑全表校验循环(21000 / 105000 次格检查) | 108.2ms | 541.3ms |
errorCount 的小数据量标定(同一函数,仅改行数):
| 行数 | 格检查次数 | 重算耗时 |
|---|---|---|
| 10 | 220 | 0.4ms |
| 100 | 2 200 | 6.7ms |
| 1000 | 22 000 | 58ms |
| 5000 | 110 000 | 215.1ms |
严格线性 → 确认是 O(行数 × 列数),且完全由行数放大。
交替 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 |
| 移除全部 editor | 25.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 层的开销; 但它也说明去掉编辑器不是可行的修法(行内编辑是产品能力),只能定向削减这部分开销。
.stk-table 自身,overflow: auto)触发原生 scroll。onTableScroll 用 rAF 节流后调用 updateVirtualScrollY(stk-table-vue/src/StkTable/StkTable.vue:1927-1964),换算出新的 startIndex / endIndex,写回 virtualScroll 这个 shallowRef 并 triggerRef。--cw / min-width / 固定列 left·right)被重写 → 浏览器每帧各做 1 次样式重算与布局(实测 RecalcStyleCount 与 LayoutCount 均约 1 次/帧),随后是大面积重绘。displayValue 里重复跑的 validate / checkPrecision / formatDisplayValue,单帧落到 60~90ms,即 11~18 FPS。handleAreaSelectionChange → activateEditing → editingActive.value = true(G3Table.vue:584-609、G3Table.vue:1255-1257)。displayColumns 依赖 editingActive.value(G3Table.vue:923-925),于是 22 个列全部 map 成新对象。resolveCustomCell 的注入判据里第一项就是 editingActive(utils/column.ts:121-134),因此 text / number / date 这些原本"非编辑态零组件开销"的列,此刻全部被挂上 customCell。props.columns → 引用变化 → 底座 watch 触发 handleDealColumns() + updateMaxRowSpan(),并在 nextTick 里 initVirtualScrollX() + updateFixedShadow() + updateCustomScrollbar()(stk-table-vue/src/StkTable/StkTable.vue:1240-1252)。tableHeaders 更新 → cellStyleMap 重建(StkTable.vue:1433-1464,逐列重算 --cw / min-width / max-width / 固定列样式字符串)→ 全表重渲染 + 编辑器挂载 + 焦点恢复。
实测代价:394ms、966 次 tbody 节点变更。
注意这是每次页面加载只发生一次的成本;此后 editingActive 恒为 true,不再重建列 —— 这也解释了为什么"再点同一格"只要 0.6ms。
activateEditing 把整行标记为 editing,并在 WeakMap 里存 focusCol(G3Table.vue:439-483、G3Table.vue:584-609)。createEditableCell 里只有 date 类型是"只给当前格挂编辑器",其余类型(text / number)在行编辑态下整行都挂(utils/editable.ts:1177-1204)。代码里已注明 date 必须"只挂当前格"的理由(切换格要重新挂载才能弹出日历面板,utils/editable.ts:1187-1201)。把同一策略推广到 text / number 是降低换行成本最直接的方向,但要一并处理键盘导航、Tab 跳格与焦点恢复这几条依赖"整行已挂载"的链路,见 §4.3 的取舍分析。
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)在保存时已经独立跑了一遍同样的全表校验,两者重复。
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% 采样,与这个分配量吻合。
横向滚动单独测的一组数字:
| 配置 | 横向帧率 |
|---|---|
页面默认(fixedColShadow=true,G3Table 的 sticky 补丁生效) | 22.9 FPS |
内存里把 fixedColShadow 置 false | 24.1 FPS |
?g3-fixed-patch=off(关掉 sticky 补丁) | 44.4 FPS |
把所有 .fixed-cell 强制 position: static | 49.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。
position、保留 z-index 的干净 A/B 重测(见 §5)。
另外,该 sticky 补丁本身是为了修"冻结列横滚抖动"而加的(packages/ui/src/table/style/_overrides.scss:416-477,
根因是底座只把 position: sticky 挂在滚动位置算出来的 --active 类上)。
所以"关掉补丁"不是可选修法,只能找等价但更省的实现。
.stk-tbody-main 挂了 will-change: transform,注释写明是"transform 虚拟滚动模式"用的;
而底座只在 transform 模式(.exp-scroll-y)下才加(stk-table-vue/src/StkTable/style.less:392-396)。
本页 scrollbar=false、走原生滚动,这层提升属于白付成本(库在容器上另有 will-change: scroll-position,style.less:81)。
:has() 规则(G3Table.vue 内,用于从内容节点反查 td 的编辑/校验/只读态)。
:has() 在样式重算时需要做子树匹配,是每帧样式重算的放大器之一;但克隆对照证明它单独不构成瓶颈,属次要项。
inset 0 0 0 100vmax 叠行状态色(style/_overrides.scss:497、509、540)。
这是为了在不覆盖库的 background-image 边框的前提下叠色,代价是每格一个巨大的内阴影;同样属次要项。
按「收益 / 风险」排序。P0 = 建议先做,P1 = 值得做但需配套验证,P2 = 顺手清理。
位置g3soft-erp/web/src/views/table-test/index.vue:615-625
现状依赖深层响应式 rows 的 computed,任何行字段写入都让全体「行数 × 22 列」重跑一遍校验,只为更新一个徽章。
改法三选一,按侵入度递增:
computed 换成普通 ref,只在「刷新 / 保存 / 切换数据量」时显式重算一次。徽章从"实时精确"变成"提交后更新",对演示页完全够用。watch(flush: 'post')+ rAF/200ms 防抖写入 ref,连续输入时只算最后一次。预期1000 行每次提交省 31~69ms,5000 行省 215~434ms。
风险极低。它只影响一个展示徽章;findFirstError 在保存时仍会独立跑全表校验,提交把关不受影响。
验证点「保存明细」的端到端耗时(当前 1000 行 318ms / 5000 行 612ms)应显著下降。
位置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 变更)。
改法(建议按这个顺序试)
displayColumns 做 Object.is 逐列比对,确认到底有多少列真的需要换新对象。
目前实现是无条件 props.columns.map(...),即使某列的 customCell 结果与上一轮完全相同,也会产出新对象 —— 只对"结果确实变化"的列产出新对象,就已经能缩小波及面。
editingActive 的 true / false 各缓存一份已解析的列数组,翻转时直接复用缓存,避免重复跑 22 次 resolveCustomCell。
注意:这不能消除底座 watch(数组引用仍会变),收益主要在列解析本身,属小头。
customCell 的注入不依赖 editingActive,改由单元格渲染函数内部读一个响应式开关来决定"渲染展示态还是编辑器"。
这样翻真相位只变单元格内容,props.columns 引用恒定,底座的 handleDealColumns / cellStyleMap 全部不触发。
预期首次点击 394ms → 目标 < 50ms((c) 若能落地,理论上可降到与"点同一格"同档的亚毫秒级)。
风险(c) 面较大,牵动编辑态渲染、校验态可见性(非编辑行也要能看见红底,这是刻意设计,见 column.ts:96-107)与固定列灰底标记,需要 e2e 兜底。建议先落 (a) 拿到真实收益数据再决定是否推进 (c)。
验证packages/ui/e2e/table.spec.ts 已有的行内编辑用例;外加本报告 §5 的"首次点击 → 编辑器就绪"计时。
位置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),所以缓存键不能只看本格值 —— 需要把"依赖同行其它列"的情形也纳入失效条件(保守做法:按行做版本号,行内任一字段变更即整行失效)。
位置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),提到模块级后该警告的触发次数会变,但不影响功能。
位置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 用例,以及"进入编辑行高不变"这类几何用例。
位置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 完全不动,交替多轮取中位;RecalcStyleCount / LayoutCount / Paint 定位成本落在哪一层;updateFixedShadow 的 class 翻转收敛为只在跨越边界时发生(useFixedCol.ts:136-152 的判据用的是滚动位置,本就是边界敏感的),或改用 CSS 变量 / :nth-child 表达阴影,避免整列 td 的 class 反复 patch。风险中。sticky 补丁是为修"冻结列横滚抖动"而存在的真实修复,任何替换都必须先复现并验证那个抖动不再出现。
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 高的大合成层。
风险低;需确认没有别的地方依赖这层提升(例如某些浏览器的平滑度)。
位置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.mjs | headless / 强制 GPU / headed 三种渲染路径对照 |
| probe-table-final.mjs | editor 增删 A/B、点击分场景、errorCount 与保存耗时 |
| probe-table-churn.mjs | 横向滚动 class 抖动与 sticky 对照 |
| probe-table-prod.mjs | 生产构建对照(未完成,见 §6) |
建议的回归验证口径(修完后对照本报告基线的数值):
提示:直接访问 /table-test 会因缺 menu.table_test 授权被送到 /403。
要么用探针脚本里的权限响应拦截,要么给 demo_zhangwei 补一条授权(sql/20_registration/13_table_test_menu.sql)。
editor、注入 <style> 覆盖 CSS、拦截 API 响应补权限),仓库源码为零改动。Performance 计数器、DevTools Trace 叶子事件汇总、LoAF 三种口径互相不完全吻合;LoAF 把可归因的脚本时间几乎全部指向底座一个 rAF 回调(StkTable.js,sourceCharPosition≈90337),由于是打包产物、函数名为空,该偏移只能作为线索,不能当作精确行号。本报告因此只使用那些"方向明确、且被对照实验支撑"的结论。