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,278 @@
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()