Files
workspace/code/g3soft-libs/packages/ui/probe-table-decisive.mjs
T
2026-10-16 17:30:17 +08:00

431 lines
17 KiB
JavaScript
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.
import { chromium } from '@playwright/test'
/**
* /table-test 探针 v10 —— 决定性归因(只读诊断,不改仓库代码)。
*
* 三个关键实验:
*
* ① 冻结克隆对照(最关键)
* 把当前 `.stk-table` 的 outerHTML 克隆一份、塞进同样尺寸的容器,然后滚**克隆**。
* 克隆不受 Vue 管理 —— 滚动它不触发任何 JS 重渲染。
* · 克隆也慢 → 瓶颈在浏览器侧(样式/布局/绘制/合成)
* · 克隆很快 → 瓶颈在 Vue/库的 JS 重渲染
*
* ② LoAF 完整字段(含 forcedStyleAndLayoutDuration / invoker / 暂停时长)
*
* ③ 数据量对照再确认:100 行(虚拟滚动关闭,无逐帧 DOM 改动)vs 1000 行(虚拟开启)
*
* ④ errorCount:一次真实「编辑提交」会额外付多少主线程时间
*/
const URL_BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
const median = (a) => {
const s = [...a].sort((x, y) => x - y)
return s.length ? s[Math.floor(s.length / 2)] : null
}
const r1 = (n) => (n == null ? null : +n.toFixed(1))
const browser = await chromium.launch({ headless: false })
const context = await browser.newContext({ viewport: { width: 1600, height: 900 } })
await context.route('**/api/data/loaddata', async (route) => {
let viewName = ''
try {
viewName = JSON.parse(route.request().postData() || '').view_name || ''
} catch {
/* ignore */
}
const response = await route.fetch()
if (viewName !== 's_power' && viewName !== 's_user_power') return route.fulfill({ response })
let json
try {
json = await response.json()
} catch {
return route.fulfill({ response })
}
const rows = Array.isArray(json?.data) ? json.data : []
if (viewName === 's_power') {
if (!rows.some((r) => r.b_id === MENU_CODE)) rows.push({ b_id: MENU_CODE, b_canuse: 1 })
} else if (!rows.some((r) => r.b_power_id === MENU_CODE)) {
rows.push({ b_user_id: 'demo_zhangwei', b_power_id: MENU_CODE, b_canuse: 1 })
}
json.data = rows
await route.fulfill({
response,
body: JSON.stringify(json),
headers: { ...response.headers(), 'content-type': 'application/json' },
})
})
const page = await context.newPage()
page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 200)))
await page.goto(`${URL_BASE}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
/** 通用滚动驱动:对指定选择器('live' | 'clone')滚动并测帧率 */
async function drive(which, { axis = 'y', steps = 60, px = 60 } = {}) {
return page.evaluate(
async ({ which, axis, steps, px }) => {
const sel = which === 'live' ? '#__probe_live .stk-table' : '#__probe_clone .stk-table'
const cont = document.querySelector(sel)
if (!cont) return { error: 'missing ' + sel }
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
const frames = []
let last = performance.now()
let stop = false
const tick = () => {
const now = performance.now()
frames.push(now - last)
last = now
if (!stop) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
for (let i = 0; i < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
if (axis === 'y') {
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + px)
} else {
const max = cont.scrollWidth - cont.clientWidth
cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + px)
}
}
stop = true
const sorted = [...frames].sort((a, b) => a - b)
const avg = frames.reduce((a, b) => a + b, 0) / frames.length
const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
return {
fps: +(1000 / avg).toFixed(1),
avg: +avg.toFixed(1),
p95: +q(0.95).toFixed(1),
scrollH: cont.scrollHeight,
clientH: cont.clientHeight,
scrollW: cont.scrollWidth,
clientW: cont.clientWidth,
}
},
{ which, axis, steps, px },
)
}
/* ============================================================
* ① 冻结克隆对照
* ============================================================ */
console.log('################ ① 冻结克隆对照 ################')
const setup = await page.evaluate(() => {
const live = document.querySelector('.stk-table')
if (!live) return { error: 'no live table' }
const r = live.getBoundingClientRect()
// 给 live 包一个稳定 id 便于选择
live.id = '__probe_live_inner'
if (!live.parentElement.id) live.parentElement.id = '__probe_live'
// 克隆整棵子树(含 530 个 td / 全部 class),放进同样尺寸的容器
const clone = live.cloneNode(true)
clone.id = '__probe_clone_inner'
clone.removeAttribute('data-v-app')
const holder = document.createElement('div')
holder.id = '__probe_clone'
holder.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;height:${r.height}px;z-index:99999;background:#fff;`
holder.appendChild(clone)
document.body.appendChild(holder)
return {
width: r.width,
height: r.height,
liveSel: '#__probe_live .stk-table',
cloneSel: '#__probe_clone .stk-table',
liveTds: live.querySelectorAll('tbody td').length,
cloneTds: clone.querySelectorAll('tbody td').length,
}
})
console.log('克隆搭建:', JSON.stringify(setup))
// 隐藏克隆时先测 live(避免克隆覆盖带来的合成差异)
await page.evaluate(() => {
const h = document.querySelector('#__probe_clone')
if (h) h.style.visibility = 'hidden'
})
await page.waitForTimeout(300)
const liveY = []
for (let i = 0; i < 3; i += 1) liveY.push(await drive('live', { axis: 'y', steps: 55, px: 60 }))
await page.evaluate(() => {
const h = document.querySelector('#__probe_clone')
if (h) h.remove()
})
console.log('live(Vue 管理,虚拟滚动逐帧改 DOM)纵向 ×3:', JSON.stringify(liveY.map((r) => r.fps)))
/* --- 重建克隆:A) 原样克隆(含 sticky / 渐变 / class) --- */
async function cloneTest(label, mutateCss) {
await page.evaluate((css) => {
document.querySelector('#__probe_clone')?.remove()
const live = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table')
const r = live.getBoundingClientRect()
const clone = live.cloneNode(true)
clone.removeAttribute('id')
const holder = document.createElement('div')
holder.id = '__probe_clone'
holder.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;height:${r.height}px;z-index:99999;background:#fff;`
// 绝对定位的 mask:把 live 盖住(量的是可见区域的真实光栅化成本)
holder.appendChild(clone)
document.body.appendChild(holder)
if (css) {
const s = document.createElement('style')
s.dataset.clone = '1'
s.textContent = css
document.head.appendChild(s)
}
}, mutateCss || '')
await page.waitForTimeout(500)
const runs = []
for (let i = 0; i < 3; i += 1) runs.push(await drive('clone', { axis: 'y', steps: 55, px: 60 }))
const runsX = []
for (let i = 0; i < 2; i += 1) runsX.push(await drive('clone', { axis: 'x', steps: 45, px: 80 }))
console.log(
`${label}\n 纵 ${runs.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runs.map((r) => r.fps)))}) | 横 ${runsX.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runsX.map((r) => r.fps)))})`,
)
await page.evaluate(() => {
document.querySelectorAll('style[data-clone]').forEach((s) => s.remove())
document.querySelector('#__probe_clone')?.remove()
})
await page.waitForTimeout(200)
return { y: r1(median(runs.map((r) => r.fps))), x: r1(median(runsX.map((r) => r.fps))) }
}
console.log('\n--- 克隆(无 Vue 参与,纯浏览器滚动同一份 DOM)---')
const cloneResult = await cloneTest('A) 原样克隆')
await cloneTest(
'B) 克隆 + 去掉固定列 sticky',
`#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; }`,
)
await cloneTest(
'C) 克隆 + 去掉单元格背景渐变(格线)',
`#__probe_clone td, #__probe_clone th { background-image: none !important; }`,
)
await cloneTest(
'D) 克隆 + 去掉 sticky 与全部 box-shadow',
`#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; }
#__probe_clone td, #__probe_clone th, #__probe_clone tr { box-shadow: none !important; }`,
)
/* ============================================================
* ② LoAF 完整字段
* ============================================================ */
console.log('\n################ ② LoAF 完整字段(纵向滚动)################')
const loaf = await page.evaluate(async () => {
const cont = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const entries = []
const obs = new PerformanceObserver((l) => {
for (const e of l.getEntries()) entries.push(e)
})
obs.observe({ type: 'long-animation-frame', buffered: false })
for (let i = 0; i < 45; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60)
}
await new Promise((r) => setTimeout(r, 250))
obs.disconnect()
const scripts = []
const frames = []
for (const e of entries) {
frames.push({
duration: +e.duration.toFixed(1),
blocking: +(e.blockingDuration || 0).toFixed(1),
renderStart: +(e.renderStart || 0).toFixed(1),
styleLayoutStart: +(e.styleAndLayoutStart || 0).toFixed(1),
scripts: (e.scripts || []).map((s) => ({
name: s.name,
entryType: s.entryType,
invoker: s.invoker,
invokerType: s.invokerType,
duration: +s.duration.toFixed(1),
forcedStyleAndLayout:
s.forcedStyleAndLayoutDuration != null ? +s.forcedStyleAndLayoutDuration.toFixed(1) : undefined,
pause: s.pauseDuration != null ? +s.pauseDuration.toFixed(1) : undefined,
url: (s.sourceURL || '').split('/').slice(-1)[0].split('?')[0],
fn: s.sourceFunctionName,
pos: s.sourceCharPosition,
})),
})
for (const s of e.scripts || []) scripts.push(s)
}
// 按脚本来源汇总
const byKey = new Map()
for (const s of scripts) {
const key = `${s.name || s.invoker} | ${(s.sourceURL || '').split('/').slice(-1)[0].split('?')[0]} | fn=${s.sourceFunctionName || '-'} | pos=${s.sourceCharPosition} | invokerType=${s.invokerType}`
const cur = byKey.get(key) || { total: 0, n: 0, forced: 0, pause: 0 }
cur.total += s.duration
cur.n += 1
cur.forced += s.forcedStyleAndLayoutDuration || 0
cur.pause += s.pauseDuration || 0
byKey.set(key, cur)
}
return {
loafCount: frames.length,
totalLoafMs: +frames.reduce((a, f) => a + f.duration, 0).toFixed(0),
totalBlockingMs: +frames.reduce((a, f) => a + f.blocking, 0).toFixed(0),
sampleFrames: frames.slice(0, 3),
scriptSources: [...byKey.entries()]
.map(([k, v]) => ({
key: k,
totalMs: +v.total.toFixed(1),
calls: v.n,
forcedStyleLayoutMs: +v.forced.toFixed(1),
pauseMs: +v.pause.toFixed(1),
}))
.sort((a, b) => b.totalMs - a.totalMs)
.slice(0, 8),
}
})
console.log(JSON.stringify(loaf, null, 2))
/* ============================================================
* ③ 数据量对照(确认「逐帧 DOM 改动」是否为主因)
* ============================================================ */
console.log('\n################ ③ 数据量对照:虚拟滚动开/关 ################')
for (const n of [10, 100, 1000]) {
await page.selectOption('.booking__select', String(n))
await page.waitForTimeout(2200)
const info = await page.evaluate(() => {
const t = document.querySelector('.stk-table')
return {
cls: t.className.includes('vt-on') ? 'vt-on(虚拟开启)' : 'vt-off(全量渲染)',
trs: document.querySelectorAll('.stk-tbody-main tr').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
scrollH: t.scrollHeight,
clientH: t.clientHeight,
}
})
// 逐帧 DOM 改动计数
const churn = await page.evaluate(async () => {
const tbody = document.querySelector('.stk-tbody-main')
const cont = document.querySelector('.stk-table')
let child = 0
let attrs = 0
const obs = new MutationObserver((recs) => {
for (const m of recs) {
if (m.type === 'childList') child += m.addedNodes.length + m.removedNodes.length
else if (m.type === 'attributes') attrs += 1
}
})
obs.observe(tbody, { childList: true, subtree: true, attributes: true })
const F = 40
for (let i = 0; i < F; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60)
}
await new Promise((r) => setTimeout(r, 60))
obs.disconnect()
return { childPerFrame: +(child / F).toFixed(1), attrPerFrame: +(attrs / F).toFixed(1) }
})
const y = await drive('live', { axis: 'y', steps: 50, px: 60 })
const x = await drive('live', { axis: 'x', steps: 40, px: 80 })
console.log(
`${String(n).padStart(5)} 行 ${info.cls.padEnd(16)} tr=${String(info.trs).padStart(3)} td=${String(info.tds).padStart(4)} | 逐帧 节点${String(churn.childPerFrame).padStart(5)} 属性${String(churn.attrPerFrame).padStart(5)} | 纵 ${String(y.fps).padStart(5)}fps | 横 ${String(x.fps).padStart(5)}fps`,
)
}
/* ============================================================
* ④ errorCount:一次「编辑提交」的额外成本
* ============================================================ */
console.log('\n################ ④ 编辑提交 → errorCount 全表校验成本 ################')
await page.selectOption('.booking__select', '1000')
await page.waitForTimeout(2200)
const commitCost = await page.evaluate(() => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
if (!i) return { error: 'no page instance' }
const ss = i.setupState
const rows = ss.rows?.value ?? ss.rows
void ss.errorCount?.value // 预热
// 模拟「编辑提交」:写一个响应式行字段(editors 的 commitInputDraft 就是这么做的)
const out = []
for (let k = 0; k < 5; k += 1) {
const r = rows[k]
const t0 = performance.now()
r.remark = `${r.remark ?? ''}x`
// 等 Vue 把 errorCount 重算 + 页面重渲染跑完
const t1 = performance.now()
out.push({ writeMs: +(t1 - t0).toFixed(2) })
r.remark = String(r.remark).slice(0, -1)
}
return { rows: rows.length, out }
})
console.log('提交写入本身:', JSON.stringify(commitCost))
// 直接量 errorCount 重算
const recompute = await page.evaluate(() => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
const ss = i.setupState
const rows = ss.rows?.value ?? ss.rows
const out = []
for (let k = 0; k < 3; k += 1) {
void ss.errorCount?.value
if (rows?.length) rows[0].freight = Number(rows[0].freight) + 0.001
const t0 = performance.now()
void ss.errorCount?.value
out.push(+(performance.now() - t0).toFixed(1))
if (rows?.length) rows[0].freight = Number(rows[0].freight) - 0.001
}
return { rows: rows.length, cols: (ss.columns?.value ?? ss.columns)?.length, ms: out }
})
console.log('errorCount 重算(1000 行 × 22 列):', JSON.stringify(recompute))
// 真实点击 → 输入 → 失焦提交 的端到端
const e2e = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][2]
// 找「备注」列(最右固定列)
const tds = [...tr.querySelectorAll('td')]
const td = tds[tds.length - 1]
const r = td.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const t0 = performance.now()
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent
td.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 }))
}
let toEditor = -1
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
toEditor = performance.now() - t0
break
}
await new Promise((r2) => requestAnimationFrame(r2))
}
const input = td.querySelector('input')
if (!input) return { toEditor: +toEditor.toFixed(1), error: 'no input' }
input.focus()
input.value = `${input.value}z`
input.dispatchEvent(new Event('input', { bubbles: true }))
const t1 = performance.now()
input.blur() // 触发 commit + errorCount 重算
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => setTimeout(r2, 0))
return {
toEditorMs: +toEditor.toFixed(1),
commitMs: +(performance.now() - t1).toFixed(1),
}
})
console.log('端到端(点击→编辑器 / 失焦提交):', JSON.stringify(e2e))
await browser.close()