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

335 lines
13 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 探针 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()