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()