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

279 lines
11 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 探针 v9 —— LoAF 逐帧归因 + 稳健 A/B(只读诊断,不改仓库代码)。
*
* 为什么换 Long Animation Frames(LoAF):
* 前面用的是「帧率 + CDP 计数器」,只能说明「慢」,说不清「谁慢」;
* LoAF 每个 >50ms 的帧都给:总时长、阻塞时长、脚本列表(函数名/文件/耗时)、
* style&layout 起点 —— 直接把每帧的账摊开。
*
* A/B 稳健性:交替 BASE/VAR 各 3 轮取中位;若页面元素丢失则自动重载。
*/
const URL_BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
async function newPage(browser) {
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(`${URL_BASE}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(2500)
return { context, page }
}
const median = (a) => {
if (!a.length) return null
const s = [...a].sort((x, y) => x - y)
return s[Math.floor(s.length / 2)]
}
const r1 = (n) => (n == null ? null : +n.toFixed(1))
const browser = await chromium.launch({ headless: false })
let { context, page } = await newPage(browser)
/* ============================================================
* 1. LoAF:滚动时每一帧的账
* ============================================================ */
async function loafDrive(page, { axis = 'y', steps = 70, px = 60 } = {}) {
const alive = await page.evaluate(() => !!document.querySelector('.stk-table'))
if (!alive) return { error: 'table missing' }
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
const loafs = []
let obs = null
let supported = true
try {
obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) loafs.push(e)
})
obs.observe({ type: 'long-animation-frame', buffered: false })
} catch {
supported = false
}
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)
const totalStart = performance.now()
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)
}
}
const wall = performance.now() - totalStart
stop = true
await new Promise((r) => setTimeout(r, 200))
obs?.disconnect()
const sorted = [...frames].sort((a, b) => a - b)
const avgFrame = frames.reduce((a, b) => a + b, 0) / frames.length
const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
// 脚本归因:按「文件名:行:函数」汇总 duration
const scripts = new Map()
let loafTotal = 0
let loafBlocking = 0
let styleLayout = 0
const perFrameDurations = []
for (const e of loafs) {
loafTotal += e.duration
loafBlocking += e.blockingDuration || 0
// styleAndLayoutStart 到 renderStart 之间是样式+布局
const sl = e.renderStart > 0 ? Math.max(0, e.renderStart - e.styleAndLayoutStart) : 0
styleLayout += sl
perFrameDurations.push(+e.duration.toFixed(1))
for (const s of e.scripts || []) {
const file = (s.sourceURL || '').split('/').slice(-1)[0].split('?')[0]
const key = `${s.name || s.invoker || '(anon)'} @ ${file}:${s.sourceFunctionName ? s.sourceFunctionName : ''}${s.sourceCharPosition != null ? ':' + s.sourceCharPosition : ''}`
scripts.set(key, (scripts.get(key) || 0) + s.duration)
}
}
return {
supported,
frames: frames.length,
fps: +(1000 / avgFrame).toFixed(1),
p50Frame: +q(0.5).toFixed(1),
p95Frame: +q(0.95).toFixed(1),
wallMs: +wall.toFixed(0),
loafCount: loafs.length,
loafTotalMs: +loafTotal.toFixed(1),
loafBlockingMs: +loafBlocking.toFixed(1),
styleLayoutMs: +styleLayout.toFixed(1),
// 未归因到脚本的剩余(≈ 样式/布局/绘制/合成/浏览器内部)
unattributedMs: +Math.max(0, loafTotal - [...scripts.values()].reduce((a, b) => a + b, 0) - styleLayout).toFixed(1),
topScripts: [...scripts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([k, v]) => [k, +v.toFixed(1)]),
}
},
{ axis, steps, px },
)
}
console.log('################ 1. LoAF:纵向滚动逐帧归因 ################')
const loafY = await loafDrive(page, { axis: 'y', steps: 70, px: 60 })
console.log(JSON.stringify(loafY, null, 2))
console.log('\n################ 2. LoAF:横向滚动逐帧归因 ################')
const loafX = await loafDrive(page, { axis: 'x', steps: 60, px: 80 })
console.log(JSON.stringify(loafX, null, 2))
/* ============================================================
* 3. 稳健 A/B
* ============================================================ */
const VARIANTS = {
'V1 去掉 td/th 的 background-image(格线渐变)': `
.stk-table td, .stk-table th { background-image: none !important; }`,
'V2 去掉固定列 sticky 定位': `
.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`,
'V3 去掉 box-shadow(含 100vmax 叠色)': `
.stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; }`,
'V4 中和 G3Table 的 :has() 规则': `
.g3-table .stk-tbody-main td:has(> .g3-table__cell-editor-root),
.g3-table .stk-tbody-main td:has(> .g3-table__cell-display) { background-color: inherit !important; box-shadow: none !important; }`,
'V5 去掉 G3Table 固定列补丁(position+z-index)': `
.stk-table td.fixed-cell { position: static !important; z-index: auto !important; }`,
'V6 V1+V2 组合': `
.stk-table td, .stk-table th { background-image: none !important; }
.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`,
}
async function applyCss(css) {
await page.evaluate((c) => {
document.querySelectorAll('style[data-probe]').forEach((s) => s.remove())
if (!c) return
const s = document.createElement('style')
s.dataset.probe = '1'
s.textContent = c
document.head.appendChild(s)
}, css)
await page.waitForTimeout(500)
}
async function quickScroll(page, { axis = 'y', steps = 60, px = 60 } = {}) {
const ok = await page.evaluate(() => !!document.querySelector('.stk-table'))
if (!ok) return null
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
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 = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
return { fps: +(1000 / avg).toFixed(1), avg: +avg.toFixed(1), p95: +q(0.95).toFixed(1) }
},
{ axis, steps, px },
)
}
console.log('\n################ 3. 交替 A/B(各 3 轮取中位)################')
for (const [label, css] of Object.entries(VARIANTS)) {
const baseY = []
const varY = []
const baseX = []
const varX = []
for (let r = 0; r < 3; r += 1) {
await applyCss('')
let m = await quickScroll(page, { axis: 'y', steps: 55, px: 60 })
if (!m) {
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 60000 })
await page.waitForTimeout(2000)
m = await quickScroll(page, { axis: 'y', steps: 55, px: 60 })
}
baseY.push(m.fps)
baseX.push((await quickScroll(page, { axis: 'x', steps: 45, px: 80 })).fps)
await applyCss(css)
const vY = (await quickScroll(page, { axis: 'y', steps: 55, px: 60 })) || { fps: null }
varY.push(vY.fps)
const vX = (await quickScroll(page, { axis: 'x', steps: 45, px: 80 })) || { fps: null }
varX.push(vX.fps)
}
await applyCss('')
const by = r1(median(baseY))
const vy = r1(median(varY))
const bx = r1(median(baseX))
const vx = r1(median(varX))
console.log(`\n${label}`)
console.log(` 纵向 基线 ${by}fps → 变体 ${vy}fps (${vy - by > 0 ? '+' : ''}${r1(vy - by)})`)
console.log(` 横向 基线 ${bx}fps → 变体 ${vx}fps (${vx - bx > 0 ? '+' : ''}${r1(vx - bx)})`)
console.log(` 明细 纵向基线=${baseY.join('/')} 变体=${varY.join('/')}`)
}
await browser.close()