384 lines
16 KiB
JavaScript
384 lines
16 KiB
JavaScript
import { chromium } from '@playwright/test'
|
||
|
||
/**
|
||
* /table-test 探针 v8 —— 可靠 A/B + 类名抖动取证(只读诊断)。
|
||
*
|
||
* v5 的 CSS A/B 数值自相矛盾(同一项时好时坏),原因是单轮测量方差极大。
|
||
* 本版改为**交替、多轮、取中位**:BASE, VAR, BASE, VAR, BASE, VAR —— 这样
|
||
* 温度/GC/后台负载等慢变因素在两组间被平摊,差值才可信。
|
||
*
|
||
* 同时取三样硬证据:
|
||
* ① 横向滚动时 td 的 class 到底从什么变成什么(类名抖动取证)
|
||
* ② 每帧的 RecalcStyleCount / LayoutCount(用 CDP 计数器,不受帧率方差影响)
|
||
* ③ 单次点击的 trace 归因(首次 / 换行 / 同格 / 换列)
|
||
*/
|
||
|
||
const BASE_URL = process.env.PROBE_URL || 'http://127.0.0.1:5090'
|
||
const MENU_CODE = 'menu.table_test'
|
||
|
||
async function makePage() {
|
||
const browser = await chromium.launch({ headless: false })
|
||
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_URL}/table-test`, { waitUntil: 'domcontentloaded' })
|
||
await page.waitForSelector('.stk-table', { timeout: 90000 })
|
||
await page.waitForTimeout(3000)
|
||
return { browser, context, page }
|
||
}
|
||
|
||
/** 一次滚动测量:返回帧间隔统计 + CDP 计数器增量 */
|
||
async function scrollMeasure(page, context, { axis = 'y', steps = 90, px = 60 } = {}) {
|
||
const client = await context.newCDPSession(page)
|
||
await client.send('Performance.enable')
|
||
const readCounters = async () => {
|
||
const m = await client.send('Performance.getMetrics')
|
||
const o = {}
|
||
for (const x of m.metrics) o[x.name] = x.value
|
||
return o
|
||
}
|
||
const before = await readCounters()
|
||
const frames = await 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 out = []
|
||
let last = performance.now()
|
||
let stop = false
|
||
const tick = () => {
|
||
const now = performance.now()
|
||
out.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
|
||
return out
|
||
},
|
||
{ axis, steps, px },
|
||
)
|
||
const after = await readCounters()
|
||
await client.detach()
|
||
|
||
const d = (k) => +((after[k] ?? 0) - (before[k] ?? 0)).toFixed(2)
|
||
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),
|
||
avgMs: +avg.toFixed(1),
|
||
p50: +q(0.5).toFixed(1),
|
||
p95: +q(0.95).toFixed(1),
|
||
frames: frames.length,
|
||
recalcStyle: d('RecalcStyleCount'),
|
||
layoutCount: d('LayoutCount'),
|
||
styleMs: d('RecalcStyleDuration'),
|
||
layoutMs: d('LayoutDuration'),
|
||
taskMs: d('TaskDuration'),
|
||
scriptMs: d('ScriptDuration'),
|
||
perFrame: {
|
||
styleMs: +(d('RecalcStyleDuration') / frames.length * 1000).toFixed(2),
|
||
layoutMs: +(d('LayoutDuration') / frames.length * 1000).toFixed(2),
|
||
taskMs: +(d('TaskDuration') / frames.length * 1000).toFixed(2),
|
||
scriptMs: +(d('ScriptDuration') / frames.length * 1000).toFixed(2),
|
||
},
|
||
}
|
||
}
|
||
|
||
const median = (arr) => {
|
||
const s = [...arr].sort((a, b) => a - b)
|
||
return s[Math.floor(s.length / 2)]
|
||
}
|
||
|
||
console.log('================= 打开页面 =================')
|
||
const { browser, context, page } = await makePage()
|
||
|
||
/* ============================================================
|
||
* ① 横向滚动时到底哪些 class 在抖
|
||
* ============================================================ */
|
||
console.log('\n=========== ① 横向滚动:类名抖动取证 ===========')
|
||
const churn = await page.evaluate(async () => {
|
||
const cont = document.querySelector('.stk-table')
|
||
cont.scrollLeft = 0
|
||
await new Promise((r) => requestAnimationFrame(r))
|
||
|
||
// 记录某个 td 的 class 变化序列
|
||
const td = document.querySelector('.stk-tbody-main td.fixed-cell')
|
||
const seq = []
|
||
const attrCount = {}
|
||
let total = 0
|
||
const obs = new MutationObserver((recs) => {
|
||
for (const m of recs) {
|
||
if (m.type !== 'attributes') continue
|
||
total += 1
|
||
const t = m.target
|
||
attrCount[m.attributeName] = (attrCount[m.attributeName] || 0) + 1
|
||
if (t === td && m.attributeName === 'class') {
|
||
const v = t.getAttribute('class') || ''
|
||
if (seq[seq.length - 1] !== v) seq.push(v)
|
||
}
|
||
}
|
||
})
|
||
obs.observe(document.querySelector('.stk-tbody-main'), {
|
||
attributes: true,
|
||
subtree: true,
|
||
attributeOldValue: true,
|
||
})
|
||
|
||
const FRAMES = 40
|
||
for (let i = 0; i < FRAMES; i += 1) {
|
||
await new Promise((r) => requestAnimationFrame(r))
|
||
const max = cont.scrollWidth - cont.clientWidth
|
||
cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + 80)
|
||
}
|
||
await new Promise((r) => setTimeout(r, 100))
|
||
obs.disconnect()
|
||
|
||
// 统计有多少个不同的 td 被改了 class
|
||
return {
|
||
FRAMES,
|
||
totalAttrWrites: total,
|
||
perFrame: +(total / FRAMES).toFixed(1),
|
||
attrNames: attrCount,
|
||
sampleTdClassSeq: seq.slice(0, 10),
|
||
fixedCellCount: document.querySelectorAll('.stk-tbody-main td.fixed-cell').length,
|
||
tdCount: document.querySelectorAll('.stk-tbody-main td').length,
|
||
}
|
||
})
|
||
console.log(JSON.stringify(churn, null, 2))
|
||
|
||
/* ============================================================
|
||
* ② 交替 A/B:用 CDP 计数器(不受帧率方差影响)
|
||
* ============================================================ */
|
||
const VARIANTS = {
|
||
'V0 基线(不动 CSS)': '',
|
||
'V1 去 td/th 背景渐变(格线)': `.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 去全部 :has() 规则(重写为不匹配)': `
|
||
.g3-table td:has(> .g3-table__cell-editor-root.is-cell-editing),
|
||
.g3-table td:has(> .g3-table__cell-editor-root.is-editor-error),
|
||
.g3-table td:has(> .g3-table__cell-display.is-editor-error),
|
||
.g3-table td:has(> .g3-table__cell-editor-root.is-editor-warning),
|
||
.g3-table td:has(> .g3-table__cell-display.is-editor-warning),
|
||
.g3-table td:has(> .g3-table__cell-display.g3-table__cell-readonly) { background-color: inherit !important; box-shadow: none !important; }`,
|
||
'V4 去 box-shadow(含 100vmax 叠色)': `.stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; }`,
|
||
'V5 去文字溢出省略': `.stk-table .table-cell-wrapper { text-overflow: clip !important; overflow: visible !important; }`,
|
||
'V6 V1+V2+V4 组合': `
|
||
.stk-table td, .stk-table th { background-image: none !important; box-shadow: none !important; }
|
||
.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }`,
|
||
}
|
||
|
||
async function applyVariant(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(600)
|
||
}
|
||
|
||
console.log('\n=========== ② 交替 A/B(横向滚动,CDP 计数器)===========')
|
||
console.log('每格显示:fps / 每帧 样式重算ms + 布局ms + 主线程任务ms')
|
||
|
||
const ROUNDS = 3
|
||
for (const [label, css] of Object.entries(VARIANTS)) {
|
||
if (label.startsWith('V0')) continue
|
||
const baseRuns = []
|
||
const varRuns = []
|
||
for (let r = 0; r < ROUNDS; r += 1) {
|
||
await applyVariant('')
|
||
baseRuns.push(await scrollMeasure(page, context, { axis: 'x', steps: 60, px: 80 }))
|
||
await applyVariant(css)
|
||
varRuns.push(await scrollMeasure(page, context, { axis: 'x', steps: 60, px: 80 }))
|
||
}
|
||
await applyVariant('')
|
||
const f = (runs, k) => median(runs.map((r) => r[k]))
|
||
const pf = (runs, k) => median(runs.map((r) => r.perFrame[k]))
|
||
console.log(
|
||
`\n${label}\n 基线 : ${String(f(baseRuns, 'fps')).padStart(5)}fps | style ${String(pf(baseRuns, 'styleMs')).padStart(5)}ms | layout ${String(pf(baseRuns, 'layoutMs')).padStart(5)}ms | task ${String(pf(baseRuns, 'taskMs')).padStart(6)}ms`,
|
||
)
|
||
console.log(
|
||
` 变体 : ${String(f(varRuns, 'fps')).padStart(5)}fps | style ${String(pf(varRuns, 'styleMs')).padStart(5)}ms | layout ${String(pf(varRuns, 'layoutMs')).padStart(5)}ms | task ${String(pf(varRuns, 'taskMs')).padStart(6)}ms`,
|
||
)
|
||
const dfps = +(f(varRuns, 'fps') - f(baseRuns, 'fps')).toFixed(1)
|
||
const dstyle = +(pf(varRuns, 'styleMs') - pf(baseRuns, 'styleMs')).toFixed(2)
|
||
const dlayout = +(pf(varRuns, 'layoutMs') - pf(baseRuns, 'layoutMs')).toFixed(2)
|
||
const dtask = +(pf(varRuns, 'taskMs') - pf(baseRuns, 'taskMs')).toFixed(2)
|
||
console.log(
|
||
` 差值 : fps ${dfps > 0 ? '+' : ''}${dfps} | style ${dstyle > 0 ? '+' : ''}${dstyle}ms | layout ${dlayout > 0 ? '+' : ''}${dlayout}ms | task ${dtask > 0 ? '+' : ''}${dtask}ms`,
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
* ③ 单次点击的 trace 归因(分场景,各 3 次取中位)
|
||
* ============================================================ */
|
||
console.log('\n=========== ③ 单次点击 trace 归因 ===========')
|
||
|
||
async function traceOne(fn) {
|
||
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 fn()
|
||
await client.send('Tracing.end')
|
||
await done
|
||
await client.detach()
|
||
|
||
// 主线程总忙(只取叶子事件,避免嵌套重复计数)
|
||
const isLeaf = (e, all) => !all.some((o) => o !== e && o.tid === e.tid && o.ph === 'X' && o.ts <= e.ts && o.ts + o.dur >= e.ts + e.dur)
|
||
const main = events.filter((e) => e.ph === 'X' && e.dur)
|
||
// 选主线程 tid
|
||
const byTid = new Map()
|
||
for (const e of main) byTid.set(e.tid, (byTid.get(e.tid) || 0) + e.dur)
|
||
const tid = [...byTid.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
|
||
const mine = main.filter((e) => e.tid === tid)
|
||
const leaves = mine.filter((e) => isLeaf(e, mine))
|
||
const busy = leaves.reduce((a, e) => a + e.dur, 0)
|
||
const agg = new Map()
|
||
for (const e of leaves) agg.set(e.name, (agg.get(e.name) || 0) + e.dur)
|
||
const top = [...agg.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||
return { busyMs: +(busy / 1000).toFixed(1), top: top.map(([k, v]) => [k, +(v / 1000).toFixed(1)]) }
|
||
}
|
||
|
||
async function clickAt(rowIdx, colIdx, settle = 300) {
|
||
await page.waitForTimeout(settle)
|
||
return page.evaluate(
|
||
async ({ rowIdx, colIdx }) => {
|
||
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][rowIdx]
|
||
if (!tr) return { error: 'no row' }
|
||
const td = [...tr.querySelectorAll('td')][colIdx]
|
||
if (!td) return { error: 'no td' }
|
||
const r = td.getBoundingClientRect()
|
||
if (r.top < 0 || r.bottom > window.innerHeight) return { error: 'offscreen' }
|
||
const cx = r.left + r.width / 2
|
||
const cy = r.top + r.height / 2
|
||
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 dispatchMs = +(performance.now() - t0).toFixed(2)
|
||
// 等两帧,让 Vue flush + 布局绘制完成
|
||
await new Promise((r2) => requestAnimationFrame(r2))
|
||
await new Promise((r2) => requestAnimationFrame(r2))
|
||
return { dispatchMs, totalMs: +(performance.now() - t0).toFixed(1) }
|
||
},
|
||
{ rowIdx, colIdx },
|
||
)
|
||
}
|
||
|
||
async function scenario(label, fn, reps = 3) {
|
||
const busy = []
|
||
const detail = []
|
||
for (let i = 0; i < reps; i += 1) {
|
||
const t = await traceOne(fn)
|
||
busy.push(t.busyMs)
|
||
detail.push(t)
|
||
}
|
||
const allNames = new Map()
|
||
for (const d of detail) for (const [k, v] of d.top) allNames.set(k, [...(allNames.get(k) || []), v])
|
||
const top = [...allNames.entries()]
|
||
.map(([k, vs]) => [k, median(vs)])
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 6)
|
||
console.log(`\n${label}`)
|
||
console.log(` 主线程忙(中位) ${median(busy)}ms [全部: ${busy.join(', ')}]`)
|
||
console.log(` 主要事件: ${top.map(([k, v]) => `${k}=${v}ms`).join(' ')}`)
|
||
return { busy: median(busy), top }
|
||
}
|
||
|
||
// 重新加载,保证「首次点击」是真的首次
|
||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||
await page.waitForSelector('.stk-table', { timeout: 90000 })
|
||
await page.waitForTimeout(3000)
|
||
|
||
await scenario('A. 首次点击某格(editingActive false→true,全表列重建)', () => clickAt(3, 6, 400))
|
||
await scenario('B. 换行点击(editingActive 保持 true)', () => clickAt(9, 6, 400))
|
||
await scenario('C. 反复点同一格', () => clickAt(9, 6, 250))
|
||
await scenario('D. 同行换列(换到 select 列)', () => clickAt(9, 2, 350))
|
||
await scenario('E. 点冻结列(fixed left)', () => clickAt(9, 2, 350).then(() => clickAt(9, 0, 350)))
|
||
|
||
/* ============================================================
|
||
* ④ 汇总
|
||
* ============================================================ */
|
||
console.log('\n=========== ④ 页面规模 ===========')
|
||
const size = await page.evaluate(() => ({
|
||
tds: document.querySelectorAll('.stk-tbody-main td').length,
|
||
trs: document.querySelectorAll('.stk-tbody-main tr').length,
|
||
ths: document.querySelectorAll('thead th').length,
|
||
stickyCells: document.querySelectorAll('.stk-tbody-main td.fixed-cell').length,
|
||
cellsWithGradientBg: [...document.querySelectorAll('.stk-tbody-main td')].filter((td) =>
|
||
/gradient/.test(getComputedStyle(td).backgroundImage),
|
||
).length,
|
||
cellsWithShadow: [...document.querySelectorAll('.stk-tbody-main td')].filter(
|
||
(td) => getComputedStyle(td).boxShadow !== 'none',
|
||
).length,
|
||
hasRulesInSheets: [...document.styleSheets].reduce((n, s) => {
|
||
try {
|
||
return n + [...s.cssRules].filter((r) => r.selectorText && r.selectorText.includes(':has(')).length
|
||
} catch {
|
||
return n
|
||
}
|
||
}, 0),
|
||
totalRules: [...document.styleSheets].reduce((n, s) => {
|
||
try {
|
||
return n + s.cssRules.length
|
||
} catch {
|
||
return n
|
||
}
|
||
}, 0),
|
||
}))
|
||
console.log(JSON.stringify(size, null, 2))
|
||
|
||
await browser.close()
|