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