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