451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
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()
|