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

451 lines
18 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 性能探针 v5 —— 归因(只读诊断,不改仓库代码)。
*
* 已确认事实(v3/v4):
* · 静止 61 FPS;纵向滚动 ~11 FPS;横向滚动 ~10 FPS;编辑态纵向 ~8 FPS
* · 点击格 → 编辑器就绪:中位 139ms,p95 444ms
* · 去掉全部 editor(零 customCell)后纵向仍只有 17 FPS → 底座自身也有地板
* · 1000 行与 5000 行帧率几乎相同 → 与总行数无关(O(可见格))
*
* 本版要回答:
* Q1 每帧的 74~90ms 里,JS(Script) 与 样式/布局(Style/Layout/Paint) 各占多少?→ CDP Performance 指标
* Q2 横向滚动时到底有没有发生「本不该发生的」DOM 变更?→ MutationObserver 计数
* Q3 CSS 层里哪一条最贵?→ 注入 !important 覆盖做 A/B(背景渐变 / box-shadow / sticky)
* Q4 点击一次编辑,钱花在哪几步?→ 分阶段打点
* Q5 页面 errorCount(1000 行 × 21 列)重算一次要多久?
*/
const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
const INST = `(() => {
let el = document.querySelector('.stk-table')
let inst = el && (el.__vueParentComponent || null)
return inst || null
})()`
async function makePage() {
const browser = await chromium.launch()
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}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(2500)
return { browser, context, page }
}
/** CDP Performance 指标:分离 JS 与 样式/布局 */
async function metricsDuring(page, context, fn) {
const client = await context.newCDPSession(page)
await client.send('Performance.enable')
const pick = (m) => {
const o = {}
for (const x of m.metrics) o[x.name] = x.value
return o
}
const before = pick(await client.send('Performance.getMetrics'))
await fn()
const after = pick(await client.send('Performance.getMetrics'))
await client.detach()
const d = (k) => +((after[k] ?? 0) - (before[k] ?? 0)).toFixed(3)
return {
TaskDuration: d('TaskDuration'),
ScriptDuration: d('ScriptDuration'),
StyleDuration: d('RecalcStyleDuration'),
LayoutDuration: d('LayoutDuration'),
RecalcStyleCount: d('RecalcStyleCount'),
LayoutCount: d('LayoutCount'),
Nodes: d('Nodes'),
JSHeapMB: +((after.JSHeapUsedSize ?? 0) / 1048576).toFixed(1),
}
}
/** 滚动(真实驱动 .stk-table) */
async function doScroll(page, { axis = 'y', steps = 120, px = 60 } = {}) {
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
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)
}
}
},
{ axis, steps, px },
)
}
async function fpsOf(page, args) {
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
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 = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))]
return {
fps: +(1000 / avg).toFixed(1),
avgMs: +avg.toFixed(2),
p95Ms: +q(sorted, 0.95).toFixed(2),
maxMs: +sorted[sorted.length - 1].toFixed(1),
}
},
args,
)
}
console.log('================= 打开页面 =================')
const { browser, context, page } = await makePage()
/* ---------- Q5: 初始挂载成本(errorCount 全表校验) ---------- */
const mount = await page.evaluate((src) => {
const pageInst = (() => {
const el = document.querySelector('.booking')
let i = el?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
return i
})()
const out = { found: !!pageInst }
if (pageInst) {
const ss = pageInst.setupState
const rows = ss.rows?.value ?? ss.rows
const cols = ss.columns?.value ?? ss.columns
out.rows = rows?.length
out.cols = cols?.length
// 计时:把 errorCount 弄脏后重读,等于跑一遍「1000 行 × 21 列」的校验
const t0 = performance.now()
const v = ss.errorCount?.value
out.errorCountCachedReadMs = +(performance.now() - t0).toFixed(2)
out.errorCount = v
// 弄脏一行 → 重算
if (rows?.length) {
rows[0].freight = Number(rows[0].freight) + 0.001
const t1 = performance.now()
void ss.errorCount?.value
out.errorCountRecomputeMs = +(performance.now() - t1).toFixed(2)
rows[0].freight = Number(rows[0].freight) - 0.001
}
}
return out
}, INST)
console.log('Q5 页面状态/errorCount 重算:', JSON.stringify(mount))
/* ---------- Q1+Q2: 纵向滚动 —— JS vs 样式布局 ---------- */
console.log('\n================= Q1 纵向滚动:JS vs 样式/布局 =================')
const yMetrics = await metricsDuring(page, context, () => doScroll(page, { axis: 'y', steps: 120 }))
console.log('纵向 120 帧指标:', JSON.stringify(yMetrics))
console.log(
` → 每帧:Script ${(yMetrics.ScriptDuration / 120 * 1000).toFixed(1)}ms | Style ${(yMetrics.StyleDuration / 120 * 1000).toFixed(1)}ms | Layout ${(yMetrics.LayoutDuration / 120 * 1000).toFixed(1)}ms | 重算样式 ${(yMetrics.RecalcStyleCount / 120).toFixed(1)} 次/帧 | 布局 ${(yMetrics.LayoutCount / 120).toFixed(1)} 次/帧`,
)
/* ---------- Q2: 横向滚动期间的 DOM 变更计数 ---------- */
console.log('\n================= Q2 横向滚动:本不该发生的 DOM 变更 =================')
const mutX = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
const tbody = document.querySelector('.stk-tbody-main')
let childList = 0
let attrs = 0
let charData = 0
const changedAttrs = {}
const obs = new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'childList') childList += r.addedNodes.length + r.removedNodes.length
else if (r.type === 'attributes') {
attrs += 1
changedAttrs[r.attributeName] = (changedAttrs[r.attributeName] || 0) + 1
} else charData += 1
}
})
obs.observe(tbody, { childList: true, subtree: true, attributes: true, characterData: true })
const FRAMES = 60
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 + 60)
}
await new Promise((r) => setTimeout(r, 50))
obs.disconnect()
return { FRAMES, childList, attrs, charData, changedAttrs }
})
console.log('横向 60 帧 tbody 变更:', JSON.stringify(mutX))
console.log(
` → 每帧:节点增删 ${(mutX.childList / mutX.FRAMES).toFixed(1)} | 属性写入 ${(mutX.attrs / mutX.FRAMES).toFixed(1)}`,
)
/* ---------- Q2b: 纵向滚动 DOM 变更 ---------- */
const mutY = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
const tbody = document.querySelector('.stk-tbody-main')
let childList = 0
let attrs = 0
const obs = new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'childList') childList += r.addedNodes.length + r.removedNodes.length
else if (r.type === 'attributes') attrs += 1
}
})
obs.observe(tbody, { childList: true, subtree: true, attributes: true, characterData: true })
const FRAMES = 60
for (let i = 0; i < FRAMES; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60)
}
await new Promise((r) => setTimeout(r, 50))
obs.disconnect()
return { FRAMES, childList, attrs }
})
console.log('纵向 60 帧 tbody 变更:', JSON.stringify(mutY))
console.log(
` → 每帧:节点增删 ${(mutY.childList / mutY.FRAMES).toFixed(1)} | 属性写入 ${(mutY.attrs / mutY.FRAMES).toFixed(1)}`,
)
/* ---------- Q3: CSS 层归因(注入覆盖做 A/B) ---------- */
console.log('\n================= Q3 CSS 归因(!important 覆盖 A/B)=================')
console.log('基线 纵向:', JSON.stringify(await fpsOf(page, { axis: 'y', steps: 100, px: 60 })))
console.log('基线 横向:', JSON.stringify(await fpsOf(page, { axis: 'x', steps: 80, px: 80 })))
async function cssAB(label, css, measAxis = 'y') {
await page.addStyleTag({ content: css })
await page.waitForTimeout(500)
const y = await fpsOf(page, { axis: 'y', steps: 100, px: 60 })
const x = await fpsOf(page, { axis: 'x', steps: 80, px: 80 })
console.log(`${label} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`)
// 移除刚注入的样式
await page.evaluate(() => {
const tags = [...document.querySelectorAll('style')]
const last = tags[tags.length - 1]
if (last && last.dataset.probe) last.remove()
})
await page.waitForTimeout(300)
return { y, x }
}
// 用一个带标记的注入函数,保证能精确移除
async function cssAB2(label, css) {
await page.evaluate((c) => {
const s = document.createElement('style')
s.dataset.probe = '1'
s.textContent = c
document.head.appendChild(s)
}, css)
await page.waitForTimeout(500)
const y = await fpsOf(page, { axis: 'y', steps: 100, px: 60 })
const x = await fpsOf(page, { axis: 'x', steps: 80, px: 80 })
console.log(`${label} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`)
await page.evaluate(() => document.querySelector('style[data-probe]')?.remove())
await page.waitForTimeout(300)
return { y, x }
}
await cssAB2('① 去掉单元格背景渐变(格线)', `
.stk-table td, .stk-table th { background-image: none !important; }
`)
await cssAB2('② 去掉全部 box-shadow(含 100vmax 叠色)', `
.stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; }
`)
await cssAB2('③ 去掉 sticky 固定列定位', `
.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; }
`)
await cssAB2('④ 去掉单元格 padding/文字溢出省略', `
.stk-table td, .stk-table th { padding: 0 !important; }
.stk-table .table-cell-wrapper { overflow: visible !important; text-overflow: clip !important; white-space: normal !important; }
`)
await cssAB2('⑤ ①②③④ 全上', `
.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; }
.stk-table td, .stk-table th { padding: 0 !important; }
.stk-table .table-cell-wrapper { overflow: visible !important; text-overflow: clip !important; white-space: normal !important; }
`)
/* ---------- Q4: 点击一次编辑的分阶段耗时 ---------- */
console.log('\n================= Q4 点击进入编辑:分阶段 =================')
const clickPhases = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const out = []
const trs = [...document.querySelectorAll('.stk-tbody-main tr')].slice(1, 9)
for (const tr of trs) {
const td = [...tr.querySelectorAll('td')][6]
if (!td) continue
const r = td.getBoundingClientRect()
if (r.top < 40 || r.bottom > window.innerHeight) continue
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const t0 = performance.now()
// 阶段 1:派发点击 → 事件同步处理完
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 tDispatch = performance.now() - t0
// 阶段 2:等 Vue 把编辑器插入 DOM(rAF 轮询)
let tRender = -1
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
tRender = performance.now() - t0
break
}
await new Promise((r) => requestAnimationFrame(r))
}
// 阶段 3:再等一帧,让布局/绘制稳定
await new Promise((r) => requestAnimationFrame(r))
const tStable = performance.now() - t0
out.push({
dispatchMs: +tDispatch.toFixed(1),
renderDoneMs: +tRender.toFixed(1),
stableMs: +tStable.toFixed(1),
})
await new Promise((r) => setTimeout(r, 150))
}
return out
})
console.log('点击分阶段(ms):')
for (const p of clickPhases) console.log(' ', JSON.stringify(p))
const med = (arr) => {
const s = [...arr].sort((a, b) => a - b)
return s.length ? +s[Math.floor(s.length / 2)].toFixed(1) : null
}
console.log(
` 中位:事件处理 ${med(clickPhases.map((p) => p.dispatchMs))}ms | 编辑器进 DOM ${med(clickPhases.map((p) => p.renderDoneMs))}ms | 稳定 ${med(clickPhases.map((p) => p.stableMs))}ms`,
)
/* ---------- Q4b: 点同一格 vs 换格 ---------- */
console.log('\n================= Q4b 同格重复点击(隔离「首次挂载」成本)=================')
const repeat = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const td = [...document.querySelectorAll('.stk-tbody-main tr')][3]?.querySelectorAll('td')[6]
if (!td) return { error: 'no td' }
const r = td.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const fire = () => {
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 timings = []
for (let i = 0; i < 6; i += 1) {
const t0 = performance.now()
fire()
let done = -1
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
done = performance.now() - t0
break
}
await new Promise((r) => requestAnimationFrame(r))
}
timings.push(+done.toFixed(1))
await new Promise((r) => setTimeout(r, 200))
}
return { timings, editorRootsNow: document.querySelectorAll('.g3-table__cell-editor-root').length }
})
console.log('同格连点 6 次(ms):', JSON.stringify(repeat))
/* ---------- Q4c: 编辑态下的每键成本 ---------- */
console.log('\n================= Q4c 编辑态:敲一个字符的延迟 =================')
const keyCost = await page.evaluate(async () => {
const input = document.querySelector('.g3-table__cell-editor-root input')
if (!input) return { error: 'no input (该列可能不是 text)' }
const timings = []
for (let i = 0; i < 8; i += 1) {
const t0 = performance.now()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }))
input.value = `${input.value}a`
input.dispatchEvent(new Event('input', { bubbles: true }))
await new Promise((r) => requestAnimationFrame(r))
timings.push(+(performance.now() - t0).toFixed(1))
await new Promise((r) => setTimeout(r, 120))
}
return { timings }
})
console.log('每键 → 下一帧(ms):', JSON.stringify(keyCost))
/* ---------- 编辑态滚动再测一次 + 指标 ---------- */
console.log('\n================= 编辑态:纵向滚动 + 指标 =================')
const em = await metricsDuring(page, context, () => doScroll(page, { axis: 'y', steps: 100 }))
const efps = await fpsOf(page, { axis: 'y', steps: 100, px: 60 })
console.log('编辑态 纵向:', JSON.stringify(efps))
console.log('编辑态 100 帧指标:', JSON.stringify(em))
const edDom = await page.evaluate(() => ({
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
inputs: document.querySelectorAll('.g3-table__cell-editor-root.g3-input').length,
selects: document.querySelectorAll('.g3-table__cell-editor-root.g3-select').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
}))
console.log('编辑态 DOM:', JSON.stringify(edDom))
await browser.close()