279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
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()
|