This commit is contained in:
oneao committed 2026-10-16 17:30:17 +08:00
1 parent e7c84a82a2
commit 025d3232c4
65 files changed
+9838 -9111

No files matched your search

@@ -0,0 +1,334 @@
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()