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

384 lines
16 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 探针 v8 —— 可靠 A/B + 类名抖动取证(只读诊断)。
*
* v5 的 CSS A/B 数值自相矛盾(同一项时好时坏),原因是单轮测量方差极大。
* 本版改为**交替、多轮、取中位**:BASE, VAR, BASE, VAR, BASE, VAR —— 这样
* 温度/GC/后台负载等慢变因素在两组间被平摊,差值才可信。
*
* 同时取三样硬证据:
* ① 横向滚动时 td 的 class 到底从什么变成什么(类名抖动取证)
* ② 每帧的 RecalcStyleCount / LayoutCount(用 CDP 计数器,不受帧率方差影响)
* ③ 单次点击的 trace 归因(首次 / 换行 / 同格 / 换列)
*/
const BASE_URL = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
async function makePage() {
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(`${BASE_URL}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
return { browser, context, page }
}
/** 一次滚动测量:返回帧间隔统计 + CDP 计数器增量 */
async function scrollMeasure(page, context, { axis = 'y', steps = 90, px = 60 } = {}) {
const client = await context.newCDPSession(page)
await client.send('Performance.enable')
const readCounters = async () => {
const m = await client.send('Performance.getMetrics')
const o = {}
for (const x of m.metrics) o[x.name] = x.value
return o
}
const before = await readCounters()
const frames = await page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
const out = []
let last = performance.now()
let stop = false
const tick = () => {
const now = performance.now()
out.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
return out
},
{ axis, steps, px },
)
const after = await readCounters()
await client.detach()
const d = (k) => +((after[k] ?? 0) - (before[k] ?? 0)).toFixed(2)
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),
avgMs: +avg.toFixed(1),
p50: +q(0.5).toFixed(1),
p95: +q(0.95).toFixed(1),
frames: frames.length,
recalcStyle: d('RecalcStyleCount'),
layoutCount: d('LayoutCount'),
styleMs: d('RecalcStyleDuration'),
layoutMs: d('LayoutDuration'),
taskMs: d('TaskDuration'),
scriptMs: d('ScriptDuration'),
perFrame: {
styleMs: +(d('RecalcStyleDuration') / frames.length * 1000).toFixed(2),
layoutMs: +(d('LayoutDuration') / frames.length * 1000).toFixed(2),
taskMs: +(d('TaskDuration') / frames.length * 1000).toFixed(2),
scriptMs: +(d('ScriptDuration') / frames.length * 1000).toFixed(2),
},
}
}
const median = (arr) => {
const s = [...arr].sort((a, b) => a - b)
return s[Math.floor(s.length / 2)]
}
console.log('================= 打开页面 =================')
const { browser, context, page } = await makePage()
/* ============================================================
* ① 横向滚动时到底哪些 class 在抖
* ============================================================ */
console.log('\n=========== ① 横向滚动:类名抖动取证 ===========')
const churn = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
// 记录某个 td 的 class 变化序列
const td = document.querySelector('.stk-tbody-main td.fixed-cell')
const seq = []
const attrCount = {}
let total = 0
const obs = new MutationObserver((recs) => {
for (const m of recs) {
if (m.type !== 'attributes') continue
total += 1
const t = m.target
attrCount[m.attributeName] = (attrCount[m.attributeName] || 0) + 1
if (t === td && m.attributeName === 'class') {
const v = t.getAttribute('class') || ''
if (seq[seq.length - 1] !== v) seq.push(v)
}
}
})
obs.observe(document.querySelector('.stk-tbody-main'), {
attributes: true,
subtree: true,
attributeOldValue: true,
})
const FRAMES = 40
for (let i = 0; i < FRAMES; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollWidth - cont.clientWidth
cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + 80)
}
await new Promise((r) => setTimeout(r, 100))
obs.disconnect()
// 统计有多少个不同的 td 被改了 class
return {
FRAMES,
totalAttrWrites: total,
perFrame: +(total / FRAMES).toFixed(1),
attrNames: attrCount,
sampleTdClassSeq: seq.slice(0, 10),
fixedCellCount: document.querySelectorAll('.stk-tbody-main td.fixed-cell').length,
tdCount: document.querySelectorAll('.stk-tbody-main td').length,
}
})
console.log(JSON.stringify(churn, null, 2))
/* ============================================================
* ② 交替 A/B:用 CDP 计数器(不受帧率方差影响)
* ============================================================ */
const VARIANTS = {
'V0 基线(不动 CSS)': '',
'V1 去 td/th 背景渐变(格线)': `.stk-table td, .stk-table th { background-image: none !important; }`,
'V2 去 sticky 固定列': `.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`,
'V3 去全部 :has() 规则(重写为不匹配)': `
.g3-table td:has(> .g3-table__cell-editor-root.is-cell-editing),
.g3-table td:has(> .g3-table__cell-editor-root.is-editor-error),
.g3-table td:has(> .g3-table__cell-display.is-editor-error),
.g3-table td:has(> .g3-table__cell-editor-root.is-editor-warning),
.g3-table td:has(> .g3-table__cell-display.is-editor-warning),
.g3-table td:has(> .g3-table__cell-display.g3-table__cell-readonly) { background-color: inherit !important; box-shadow: none !important; }`,
'V4 去 box-shadow(含 100vmax 叠色)': `.stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; }`,
'V5 去文字溢出省略': `.stk-table .table-cell-wrapper { text-overflow: clip !important; overflow: visible !important; }`,
'V6 V1+V2+V4 组合': `
.stk-table td, .stk-table th { background-image: none !important; box-shadow: none !important; }
.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`,
}
async function applyVariant(css) {
await page.evaluate((c) => {
document.querySelectorAll('style[data-probe]').forEach((s) => s.remove())
if (!c) return
const s = document.createElement('style')
s.dataset.probe = '1'
s.textContent = c
document.head.appendChild(s)
}, css)
await page.waitForTimeout(600)
}
console.log('\n=========== ② 交替 A/B(横向滚动,CDP 计数器)===========')
console.log('每格显示:fps / 每帧 样式重算ms + 布局ms + 主线程任务ms')
const ROUNDS = 3
for (const [label, css] of Object.entries(VARIANTS)) {
if (label.startsWith('V0')) continue
const baseRuns = []
const varRuns = []
for (let r = 0; r < ROUNDS; r += 1) {
await applyVariant('')
baseRuns.push(await scrollMeasure(page, context, { axis: 'x', steps: 60, px: 80 }))
await applyVariant(css)
varRuns.push(await scrollMeasure(page, context, { axis: 'x', steps: 60, px: 80 }))
}
await applyVariant('')
const f = (runs, k) => median(runs.map((r) => r[k]))
const pf = (runs, k) => median(runs.map((r) => r.perFrame[k]))
console.log(
`\n${label}\n 基线 : ${String(f(baseRuns, 'fps')).padStart(5)}fps | style ${String(pf(baseRuns, 'styleMs')).padStart(5)}ms | layout ${String(pf(baseRuns, 'layoutMs')).padStart(5)}ms | task ${String(pf(baseRuns, 'taskMs')).padStart(6)}ms`,
)
console.log(
` 变体 : ${String(f(varRuns, 'fps')).padStart(5)}fps | style ${String(pf(varRuns, 'styleMs')).padStart(5)}ms | layout ${String(pf(varRuns, 'layoutMs')).padStart(5)}ms | task ${String(pf(varRuns, 'taskMs')).padStart(6)}ms`,
)
const dfps = +(f(varRuns, 'fps') - f(baseRuns, 'fps')).toFixed(1)
const dstyle = +(pf(varRuns, 'styleMs') - pf(baseRuns, 'styleMs')).toFixed(2)
const dlayout = +(pf(varRuns, 'layoutMs') - pf(baseRuns, 'layoutMs')).toFixed(2)
const dtask = +(pf(varRuns, 'taskMs') - pf(baseRuns, 'taskMs')).toFixed(2)
console.log(
` 差值 : fps ${dfps > 0 ? '+' : ''}${dfps} | style ${dstyle > 0 ? '+' : ''}${dstyle}ms | layout ${dlayout > 0 ? '+' : ''}${dlayout}ms | task ${dtask > 0 ? '+' : ''}${dtask}ms`,
)
}
/* ============================================================
* ③ 单次点击的 trace 归因(分场景,各 3 次取中位)
* ============================================================ */
console.log('\n=========== ③ 单次点击 trace 归因 ===========')
async function traceOne(fn) {
const client = await context.newCDPSession(page)
const events = []
client.on('Tracing.dataCollected', ({ value }) => events.push(...value))
const done = new Promise((res) => client.once('Tracing.tracingComplete', res))
await client.send('Tracing.start', {
categories: 'devtools.timeline,blink.user_timing',
options: 'sampling',
transferMode: 'ReportEvents',
})
await fn()
await client.send('Tracing.end')
await done
await client.detach()
// 主线程总忙(只取叶子事件,避免嵌套重复计数)
const isLeaf = (e, all) => !all.some((o) => o !== e && o.tid === e.tid && o.ph === 'X' && o.ts <= e.ts && o.ts + o.dur >= e.ts + e.dur)
const main = events.filter((e) => e.ph === 'X' && e.dur)
// 选主线程 tid
const byTid = new Map()
for (const e of main) byTid.set(e.tid, (byTid.get(e.tid) || 0) + e.dur)
const tid = [...byTid.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
const mine = main.filter((e) => e.tid === tid)
const leaves = mine.filter((e) => isLeaf(e, mine))
const busy = leaves.reduce((a, e) => a + e.dur, 0)
const agg = new Map()
for (const e of leaves) agg.set(e.name, (agg.get(e.name) || 0) + e.dur)
const top = [...agg.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
return { busyMs: +(busy / 1000).toFixed(1), top: top.map(([k, v]) => [k, +(v / 1000).toFixed(1)]) }
}
async function clickAt(rowIdx, colIdx, settle = 300) {
await page.waitForTimeout(settle)
return page.evaluate(
async ({ rowIdx, colIdx }) => {
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][rowIdx]
if (!tr) return { error: 'no row' }
const td = [...tr.querySelectorAll('td')][colIdx]
if (!td) return { error: 'no td' }
const r = td.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) return { error: 'offscreen' }
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 }),
)
}
const dispatchMs = +(performance.now() - t0).toFixed(2)
// 等两帧,让 Vue flush + 布局绘制完成
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => requestAnimationFrame(r2))
return { dispatchMs, totalMs: +(performance.now() - t0).toFixed(1) }
},
{ rowIdx, colIdx },
)
}
async function scenario(label, fn, reps = 3) {
const busy = []
const detail = []
for (let i = 0; i < reps; i += 1) {
const t = await traceOne(fn)
busy.push(t.busyMs)
detail.push(t)
}
const allNames = new Map()
for (const d of detail) for (const [k, v] of d.top) allNames.set(k, [...(allNames.get(k) || []), v])
const top = [...allNames.entries()]
.map(([k, vs]) => [k, median(vs)])
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
console.log(`\n${label}`)
console.log(` 主线程忙(中位) ${median(busy)}ms [全部: ${busy.join(', ')}]`)
console.log(` 主要事件: ${top.map(([k, v]) => `${k}=${v}ms`).join(' ')}`)
return { busy: median(busy), top }
}
// 重新加载,保证「首次点击」是真的首次
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
await scenario('A. 首次点击某格(editingActive false→true,全表列重建)', () => clickAt(3, 6, 400))
await scenario('B. 换行点击(editingActive 保持 true)', () => clickAt(9, 6, 400))
await scenario('C. 反复点同一格', () => clickAt(9, 6, 250))
await scenario('D. 同行换列(换到 select 列)', () => clickAt(9, 2, 350))
await scenario('E. 点冻结列(fixed left)', () => clickAt(9, 2, 350).then(() => clickAt(9, 0, 350)))
/* ============================================================
* ④ 汇总
* ============================================================ */
console.log('\n=========== ④ 页面规模 ===========')
const size = await page.evaluate(() => ({
tds: document.querySelectorAll('.stk-tbody-main td').length,
trs: document.querySelectorAll('.stk-tbody-main tr').length,
ths: document.querySelectorAll('thead th').length,
stickyCells: document.querySelectorAll('.stk-tbody-main td.fixed-cell').length,
cellsWithGradientBg: [...document.querySelectorAll('.stk-tbody-main td')].filter((td) =>
/gradient/.test(getComputedStyle(td).backgroundImage),
).length,
cellsWithShadow: [...document.querySelectorAll('.stk-tbody-main td')].filter(
(td) => getComputedStyle(td).boxShadow !== 'none',
).length,
hasRulesInSheets: [...document.styleSheets].reduce((n, s) => {
try {
return n + [...s.cssRules].filter((r) => r.selectorText && r.selectorText.includes(':has(')).length
} catch {
return n
}
}, 0),
totalRules: [...document.styleSheets].reduce((n, s) => {
try {
return n + s.cssRules.length
} catch {
return n
}
}, 0),
}))
console.log(JSON.stringify(size, null, 2))
await browser.close()