import { chromium } from '@playwright/test' /** * /table-test 探针 v9 —— LoAF 逐帧归因 + 稳健 A/B(只读诊断,不改仓库代码)。 * * 为什么换 Long Animation Frames(LoAF): * 前面用的是「帧率 + CDP 计数器」,只能说明「慢」,说不清「谁慢」; * LoAF 每个 >50ms 的帧都给:总时长、阻塞时长、脚本列表(函数名/文件/耗时)、 * style&layout 起点 —— 直接把每帧的账摊开。 * * A/B 稳健性:交替 BASE/VAR 各 3 轮取中位;若页面元素丢失则自动重载。 */ const URL_BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090' const MENU_CODE = 'menu.table_test' async function newPage(browser) { 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(2500) return { context, page } } const median = (a) => { if (!a.length) return null const s = [...a].sort((x, y) => x - y) return s[Math.floor(s.length / 2)] } const r1 = (n) => (n == null ? null : +n.toFixed(1)) const browser = await chromium.launch({ headless: false }) let { context, page } = await newPage(browser) /* ============================================================ * 1. LoAF:滚动时每一帧的账 * ============================================================ */ async function loafDrive(page, { axis = 'y', steps = 70, px = 60 } = {}) { const alive = await page.evaluate(() => !!document.querySelector('.stk-table')) if (!alive) return { error: 'table missing' } return 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 loafs = [] let obs = null let supported = true try { obs = new PerformanceObserver((list) => { for (const e of list.getEntries()) loafs.push(e) }) obs.observe({ type: 'long-animation-frame', buffered: false }) } catch { supported = false } 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) const totalStart = performance.now() 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) } } const wall = performance.now() - totalStart stop = true await new Promise((r) => setTimeout(r, 200)) obs?.disconnect() const sorted = [...frames].sort((a, b) => a - b) const avgFrame = frames.reduce((a, b) => a + b, 0) / frames.length const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] // 脚本归因:按「文件名:行:函数」汇总 duration const scripts = new Map() let loafTotal = 0 let loafBlocking = 0 let styleLayout = 0 const perFrameDurations = [] for (const e of loafs) { loafTotal += e.duration loafBlocking += e.blockingDuration || 0 // styleAndLayoutStart 到 renderStart 之间是样式+布局 const sl = e.renderStart > 0 ? Math.max(0, e.renderStart - e.styleAndLayoutStart) : 0 styleLayout += sl perFrameDurations.push(+e.duration.toFixed(1)) for (const s of e.scripts || []) { const file = (s.sourceURL || '').split('/').slice(-1)[0].split('?')[0] const key = `${s.name || s.invoker || '(anon)'} @ ${file}:${s.sourceFunctionName ? s.sourceFunctionName : ''}${s.sourceCharPosition != null ? ':' + s.sourceCharPosition : ''}` scripts.set(key, (scripts.get(key) || 0) + s.duration) } } return { supported, frames: frames.length, fps: +(1000 / avgFrame).toFixed(1), p50Frame: +q(0.5).toFixed(1), p95Frame: +q(0.95).toFixed(1), wallMs: +wall.toFixed(0), loafCount: loafs.length, loafTotalMs: +loafTotal.toFixed(1), loafBlockingMs: +loafBlocking.toFixed(1), styleLayoutMs: +styleLayout.toFixed(1), // 未归因到脚本的剩余(≈ 样式/布局/绘制/合成/浏览器内部) unattributedMs: +Math.max(0, loafTotal - [...scripts.values()].reduce((a, b) => a + b, 0) - styleLayout).toFixed(1), topScripts: [...scripts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 12) .map(([k, v]) => [k, +v.toFixed(1)]), } }, { axis, steps, px }, ) } console.log('################ 1. LoAF:纵向滚动逐帧归因 ################') const loafY = await loafDrive(page, { axis: 'y', steps: 70, px: 60 }) console.log(JSON.stringify(loafY, null, 2)) console.log('\n################ 2. LoAF:横向滚动逐帧归因 ################') const loafX = await loafDrive(page, { axis: 'x', steps: 60, px: 80 }) console.log(JSON.stringify(loafX, null, 2)) /* ============================================================ * 3. 稳健 A/B * ============================================================ */ const VARIANTS = { 'V1 去掉 td/th 的 background-image(格线渐变)': ` .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 去掉 box-shadow(含 100vmax 叠色)': ` .stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; }`, 'V4 中和 G3Table 的 :has() 规则': ` .g3-table .stk-tbody-main td:has(> .g3-table__cell-editor-root), .g3-table .stk-tbody-main td:has(> .g3-table__cell-display) { background-color: inherit !important; box-shadow: none !important; }`, 'V5 去掉 G3Table 固定列补丁(position+z-index)': ` .stk-table td.fixed-cell { position: static !important; z-index: auto !important; }`, 'V6 V1+V2 组合': ` .stk-table td, .stk-table th { background-image: none !important; } .stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`, } async function applyCss(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(500) } async function quickScroll(page, { axis = 'y', steps = 60, px = 60 } = {}) { const ok = await page.evaluate(() => !!document.querySelector('.stk-table')) if (!ok) return null return 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 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) } }, { axis, steps, px }, ) } console.log('\n################ 3. 交替 A/B(各 3 轮取中位)################') for (const [label, css] of Object.entries(VARIANTS)) { const baseY = [] const varY = [] const baseX = [] const varX = [] for (let r = 0; r < 3; r += 1) { await applyCss('') let m = await quickScroll(page, { axis: 'y', steps: 55, px: 60 }) if (!m) { await page.reload({ waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 60000 }) await page.waitForTimeout(2000) m = await quickScroll(page, { axis: 'y', steps: 55, px: 60 }) } baseY.push(m.fps) baseX.push((await quickScroll(page, { axis: 'x', steps: 45, px: 80 })).fps) await applyCss(css) const vY = (await quickScroll(page, { axis: 'y', steps: 55, px: 60 })) || { fps: null } varY.push(vY.fps) const vX = (await quickScroll(page, { axis: 'x', steps: 45, px: 80 })) || { fps: null } varX.push(vX.fps) } await applyCss('') const by = r1(median(baseY)) const vy = r1(median(varY)) const bx = r1(median(baseX)) const vx = r1(median(varX)) console.log(`\n${label}`) console.log(` 纵向 基线 ${by}fps → 变体 ${vy}fps (${vy - by > 0 ? '+' : ''}${r1(vy - by)})`) console.log(` 横向 基线 ${bx}fps → 变体 ${vx}fps (${vx - bx > 0 ? '+' : ''}${r1(vx - bx)})`) console.log(` 明细 纵向基线=${baseY.join('/')} 变体=${varY.join('/')}`) } await browser.close()