335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
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()
|