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

285 lines
11 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 性能探针 v3(只读诊断,不改业务代码)。
*
* v2 的教训:滚动了 `.stk-table` 的**父节点**(.g3-table),那个元素并不滚动
* (scrollHeight == clientHeight),于是「假滚动」+ 99.6% idle。
* 真正的滚动容器是 `.stk-table` 自身(style.less 里它带 overflow: auto)。
* 本版先在页面里**探测出真正可滚动的元素**再测量,不靠猜。
*/
const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
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, 300)))
await page.goto(`${BASE}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
/* ---------- 0. 找出真正滚动的元素 ---------- */
const containers = await page.evaluate(() => {
const out = []
const walk = (el, depth = 0) => {
if (depth > 14) return
const cs = getComputedStyle(el)
const oy = cs.overflowY
const ox = cs.overflowX
const scrollableY = /auto|scroll/.test(oy) && el.scrollHeight > el.clientHeight + 4
const scrollableX = /auto|scroll/.test(ox) && el.scrollWidth > el.clientWidth + 4
if (scrollableY || scrollableX) {
out.push({
tag: el.tagName,
cls: (el.className || '').toString().slice(0, 70),
oy,
ox,
scrollableY,
scrollableX,
scrollH: el.scrollHeight,
clientH: el.clientHeight,
scrollW: el.scrollWidth,
clientW: el.clientWidth,
})
}
for (const c of el.children) walk(c, depth + 1)
}
walk(document.body)
return out
})
console.log('=== 可滚动元素 ===')
console.log(JSON.stringify(containers, null, 2))
const info = await page.evaluate(() => ({
bodyRows: document.querySelectorAll('.stk-tbody-main tr').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
displaySpans: document.querySelectorAll('.g3-table__cell-display').length,
selects: document.querySelectorAll('.g3-select').length,
inputs: document.querySelectorAll('.g3-input').length,
innerCls: document.querySelector('.stk-table')?.className,
}))
console.log('=== DOM ===', JSON.stringify(info))
/* ---------- 1. 帧率测量:真正驱动滚动 ---------- */
async function measureScroll({ axis = 'y', steps = 150, px = 60, label = '' } = {}) {
const res = await page.evaluate(
async ({ axis, steps, px }) => {
// 找到真正能滚的容器
let cont = document.querySelector('.stk-table')
const canScroll = (el, a) =>
a === 'y' ? el.scrollHeight > el.clientHeight + 4 : el.scrollWidth > el.clientWidth + 4
if (!canScroll(cont, axis)) {
const all = [...document.querySelectorAll('*')]
cont = all.find((el) => canScroll(el, axis))
}
if (!cont) return { error: 'no scrollable container' }
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)
const sync = []
for (let i = 0; i < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const t0 = performance.now()
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)
}
sync.push(performance.now() - t0)
}
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 {
cont: cont.className?.toString().slice(0, 40),
frames: frames.length,
fps: +(1000 / avg).toFixed(1),
avgMs: +avg.toFixed(2),
p50: +q(sorted, 0.5).toFixed(2),
p95: +q(sorted, 0.95).toFixed(2),
p99: +q(sorted, 0.99).toFixed(2),
max: +sorted[sorted.length - 1].toFixed(2),
over16: frames.filter((f) => f > 16.7).length,
over33: frames.filter((f) => f > 33).length,
over50: frames.filter((f) => f > 50).length,
syncAvg: +(sync.reduce((a, b) => a + b, 0) / sync.length).toFixed(2),
syncP95: +q([...sync].sort((a, b) => a - b), 0.95).toFixed(2),
}
},
{ axis, steps, px },
)
console.log(`滚动[${label}] ${JSON.stringify(res)}`)
return res
}
/* ---------- 2. 点击延迟 ---------- */
async function measureClick({ label = '' } = {}) {
const res = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const trs = [...document.querySelectorAll('.stk-tbody-main tr')].slice(1, 13)
const out = []
for (const tr of trs) {
const tds = [...tr.querySelectorAll('td')]
const td = tds[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()
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 }),
)
}
// 等到编辑器真的出现在 DOM 里(轮询 + rAF,测「用户可感知的就绪时刻」)
let toEditor = -1
const deadline = performance.now() + 2000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
toEditor = performance.now() - t0
break
}
await new Promise((r) => requestAnimationFrame(r))
}
await new Promise((r) => setTimeout(r, 120))
out.push(+toEditor.toFixed(2))
}
return out
})
const vals = res.filter((v) => v >= 0).sort((a, b) => a - b)
const summary = {
n: vals.length,
min: vals.length ? +vals[0].toFixed(1) : null,
median: vals.length ? +vals[Math.floor(vals.length / 2)].toFixed(1) : null,
p95: vals.length ? +vals[Math.floor(vals.length * 0.95)].toFixed(1) : null,
max: vals.length ? +vals[vals.length - 1].toFixed(1) : null,
all: vals.map((v) => +v.toFixed(1)),
}
console.log(`点击→编辑器就绪[${label}] ${JSON.stringify(summary)}`)
return summary
}
/* ---------- 3. CPU profile ---------- */
async function profile(label, action) {
const client = await context.newCDPSession(page)
await client.send('Profiler.enable')
await client.send('Profiler.setSamplingInterval', { interval: 150 })
await client.send('Profiler.start')
await action()
const { profile: p } = await client.send('Profiler.stop')
await client.detach()
const byId = new Map(p.nodes.map((n) => [n.id, n]))
const self = new Map()
const total = p.samples.length || 1
for (const id of p.samples) {
const n = byId.get(id)
if (!n) continue
const cf = n.callFrame
const file = (cf.url || '').split('/').slice(-1)[0].split('?')[0]
const key = `${cf.functionName || '(anon)'} @ ${file}:${cf.lineNumber + 1}`
self.set(key, (self.get(key) || 0) + 1)
}
console.log(`\n===== CPU 热点:${label}(总采样 ${total})=====`)
for (const [k, c] of [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 28)) {
console.log(`${((c / total) * 100).toFixed(1).padStart(5)}% ${k}`)
}
return self
}
/* ---------- 执行 ---------- */
console.log('\n---------- 静止基线(无滚动)----------')
await measureScroll({ axis: 'y', steps: 60, px: 0, label: '基线-不动' })
console.log('\n---------- 纵向滚动 ---------')
await measureScroll({ axis: 'y', steps: 150, px: 60, label: 'Y' })
console.log('\n---------- 横向滚动 ---------')
await measureScroll({ axis: 'x', steps: 100, px: 80, label: 'X' })
await profile('纵向滚动', () => measureScroll({ axis: 'y', steps: 200, px: 60, label: 'Y-prof' }))
console.log('\n---------- 点击 ---------')
await measureClick({ label: '就绪态' })
await profile('点击进入编辑', () => measureClick({ label: 'prof' }))
console.log('\n---------- 编辑态下的纵向滚动 ---------')
await page.evaluate(() => {
const td = [...document.querySelectorAll('.stk-tbody-main tr')][2]?.querySelectorAll('td')[6]
if (!td) return
const r = td.getBoundingClientRect()
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: r.left + r.width / 2,
clientY: r.top + r.height / 2,
button: 0,
}),
)
}
})
await page.waitForTimeout(500)
const ed = await page.evaluate(() => ({
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
selects: document.querySelectorAll('.g3-select').length,
inputs: document.querySelectorAll('.g3-input').length,
datePickers: document.querySelectorAll('.g3-date-picker').length,
}))
console.log('编辑态 DOM:', JSON.stringify(ed))
await measureScroll({ axis: 'y', steps: 150, px: 60, label: 'Y-编辑态' })
await browser.close()