import { chromium } from '@playwright/test' /** * /table-test 探针 v6 —— 用 CDP Trace 做「每帧花在哪」的硬归因(只读诊断)。 * * 前几版暴露的问题:帧率数字抖动大(同一配置 10~19fps 都出现过), * 单次 A/B 不可信。本版: * 1. 基线重复 3 轮,公开全部数值,不挑好看的那次; * 2. 用 DevTools trace 按事件类型汇总耗时:FunctionCall(JS) / UpdateLayoutTree(Style) * / Layout / Paint / RasterTask —— 直接看「谁在吃帧预算」; * 3. 点击进入编辑:区分「首格(editingActive false→true)」/「换行」/「同格重复」, * 并数清每次点击引发的 tbody 变更与长任务; * 4. errorCount 随行数增长(页面级全表校验的 O(行×列) 成本)。 */ const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090' const MENU_CODE = 'menu.table_test' async function makePage() { const browser = await chromium.launch() 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}/table-test`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 90000 }) await page.waitForTimeout(3000) return { browser, context, page } } /* ---------- 把表格滚回顶部并等虚拟滚动真正重建 ---------- */ async function resetScroll(page) { await page.evaluate(() => { const c = document.querySelector('.stk-table') c.scrollTop = 0 c.scrollLeft = 0 }) await page.waitForTimeout(400) await page.evaluate( () => new Promise((r) => { requestAnimationFrame(() => requestAnimationFrame(r)) }), ) } /* ---------- 帧率(重复多轮) ---------- */ async function fps(page, { axis = 'y', steps = 120, px = 60 } = {}) { return page.evaluate( async ({ axis, steps, px }) => { const cont = document.querySelector('.stk-table') 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 = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))] return { fps: +(1000 / avg).toFixed(1), avg: +avg.toFixed(1), p50: +q(sorted, 0.5).toFixed(1), p95: +q(sorted, 0.95).toFixed(1), max: +sorted[sorted.length - 1].toFixed(0), } }, { axis, steps, px }, ) } /* ---------- Trace:按事件类型汇总 ---------- */ async function trace(page, context, action) { 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 action() await client.send('Tracing.end') await done await client.detach() // 只统计渲染主线程(最长的那条 tid)上的事件 const byTid = new Map() for (const e of events) { if (e.ph !== 'X' || !e.dur) continue byTid.set(e.tid, (byTid.get(e.tid) || 0) + e.dur) } const mainTid = [...byTid.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] const agg = new Map() let total = 0 for (const e of events) { if (e.ph !== 'X' || !e.dur || e.tid !== mainTid) continue agg.set(e.name, (agg.get(e.name) || 0) + e.dur) total += e.dur } // 按类别归并(trace 事件名 → 归属) const groups = { js: 0, style: 0, layout: 0, paint: 0, raster: 0, other: 0 } for (const [name, us] of agg) { if (name === 'FunctionCall' || name === 'EvaluateScript' || name === 'RunMicrotasks' || name === 'TimerFire' || name === 'EventDispatch') groups.js += us else if (name === 'UpdateLayoutTree' || name === 'ParseAuthorStyleSheet' || name === 'StyleRecalcInvalidationTracking') groups.style += us else if (name === 'Layout' || name === 'InvalidateLayout') groups.layout += us else if (name === 'Paint') groups.paint += us else if (name === 'RasterTask' || name === 'CompositeLayers' || name === 'UpdateLayer') groups.raster += us else groups.other += us } return { totalMs: +(total / 1000).toFixed(1), groups: Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, +(v / 1000).toFixed(1)])), top: [...agg.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 14) .map(([k, v]) => [k, +(v / 1000).toFixed(1)]), } } /* ============================================================ */ console.log('################ 1. 基线帧率(纵向 ×3 轮)################') const { browser, context, page } = await makePage() const runs = [] for (let i = 0; i < 3; i += 1) { await resetScroll(page) runs.push(await fps(page, { axis: 'y', steps: 120, px: 60 })) } console.log('纵向 3 轮:', JSON.stringify(runs)) console.log( ` 中位 fps ≈ ${runs.map((r) => r.fps).sort((a, b) => a - b)[1]} (静止时 61)`, ) const xruns = [] for (let i = 0; i < 2; i += 1) { await resetScroll(page) xruns.push(await fps(page, { axis: 'x', steps: 80, px: 80 })) } console.log('横向 2 轮:', JSON.stringify(xruns)) /* ============================================================ */ console.log('\n################ 2. Trace:滚动的一帧花在哪 ################') const scrollTrace = await trace(page, context, () => fps(page, { axis: 'y', steps: 100, px: 60 })) console.log(`主线程总忙 ${scrollTrace.totalMs}ms(100 帧 ≈ ${(scrollTrace.totalMs / 100).toFixed(1)}ms/帧)`) console.log(' 分类:', JSON.stringify(scrollTrace.groups)) for (const [name, ms] of scrollTrace.top) console.log(` ${String(ms).padStart(8)}ms ${name}`) /* ============================================================ */ console.log('\n################ 3. 点击进入编辑:三种场景 ################') /** 点击某个 td,返回分阶段耗时 + 该次点击引发的 DOM 变更/长任务 */ async function clickCell(page, { rowIdx = 3, colIdx = 6, settle = 250 } = {}) { await page.waitForTimeout(settle) return page.evaluate( async ({ rowIdx, colIdx }) => { const trs = [...document.querySelectorAll('.stk-tbody-main tr')] const tr = trs[rowIdx] if (!tr) return { error: 'no row ' + rowIdx } const tds = [...tr.querySelectorAll('td')] const td = tds[colIdx] if (!td) return { error: 'no td ' + colIdx } const r = td.getBoundingClientRect() if (r.top < 0 || r.bottom > window.innerHeight) { return { error: `td offscreen top=${r.top.toFixed(0)} bottom=${r.bottom.toFixed(0)}` } } const cx = r.left + r.width / 2 const cy = r.top + r.height / 2 const alreadyEditing = !!td.querySelector('.g3-table__cell-editor-root') // 观测:tbody 变更 + 长任务 const tbody = document.querySelector('.stk-tbody-main') 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 longs = [] let po try { po = new PerformanceObserver((l) => { for (const e of l.getEntries()) longs.push(+e.duration.toFixed(1)) }) po.observe({ entryTypes: ['longtask'] }) } catch { /* ignore */ } 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 tDispatch = performance.now() - t0 let tRender = -1 const deadline = performance.now() + 4000 while (performance.now() < deadline) { if (td.querySelector('.g3-table__cell-editor-root')) { tRender = performance.now() - t0 break } await new Promise((r) => requestAnimationFrame(r)) } // 静置一帧 + 一点时间,收集长任务 await new Promise((r) => requestAnimationFrame(r)) await new Promise((r) => setTimeout(r, 250)) obs.disconnect() po?.disconnect() return { alreadyEditing, dispatchMs: +tDispatch.toFixed(1), totalMs: +tRender.toFixed(1), longTasks: longs, mutationChild: child, mutationAttrs: attrs, editorCount: document.querySelectorAll('.g3-table__cell-editor-root').length, } }, { rowIdx, colIdx }, ) } await resetScroll(page) console.log('场景 A:页面加载后**第一次**点击某格(editingActive false→true)') const first = await clickCell(page, { rowIdx: 3, colIdx: 6, settle: 300 }) console.log(' ', JSON.stringify(first)) console.log('场景 B:再点**另一个行**的同一列(editingActive 保持 true)') for (const r of [5, 7, 9, 11]) { const res = await clickCell(page, { rowIdx: r, colIdx: 6 }) console.log(` row${r}:`, JSON.stringify(res)) } console.log('场景 C:反复点**同一格**') for (let i = 0; i < 3; i += 1) { const res = await clickCell(page, { rowIdx: 11, colIdx: 6, settle: 200 }) console.log(` 第${i + 1}次:`, JSON.stringify(res)) } console.log('场景 D:同一行换列(点可编辑的 select 列)') for (const c of [2, 3, 4]) { const res = await clickCell(page, { rowIdx: 11, colIdx: c, settle: 250 }) console.log(` col${c}:`, JSON.stringify(res)) } /* ---------- Trace 一次「换行点击」 ---------- */ console.log('\n################ 4. Trace:换行点击花在哪 ################') await resetScroll(page) const clickTrace = await trace(page, context, async () => { await clickCell(page, { rowIdx: 3, colIdx: 6, settle: 200 }) await clickCell(page, { rowIdx: 5, colIdx: 6, settle: 200 }) await clickCell(page, { rowIdx: 7, colIdx: 6, settle: 200 }) }) console.log('主线程总忙', clickTrace.totalMs, 'ms(3 次换行点击)') console.log(' 分类:', JSON.stringify(clickTrace.groups)) for (const [name, ms] of clickTrace.top) console.log(` ${String(ms).padStart(8)}ms ${name}`) /* ============================================================ */ console.log('\n################ 5. errorCount(页面全表校验)随行数 ################') for (const n of [10, 100, 1000, 5000]) { await page.selectOption('.booking__select', String(n)) await page.waitForTimeout(2200) const res = 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 const cols = ss.columns?.value ?? ss.columns void ss.errorCount?.value // 预热 const t0 = performance.now() if (rows?.length) rows[0].freight = Number(rows[0].freight) + 0.001 void ss.errorCount?.value const ms = performance.now() - t0 if (rows?.length) rows[0].freight = Number(rows[0].freight) - 0.001 return { rows: rows.length, cols: cols.length, checks: rows.length * cols.length, recomputeMs: +ms.toFixed(1) } }) console.log(` ${JSON.stringify(res)}`) } await browser.close()