u
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user