Files
workspace/code/fms/.fps-probe/measure.mjs
T
2026-08-19 17:32:25 +08:00

218 lines
8.4 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-core'
const BASE = process.env.FMS_BASE || 'http://localhost:5082'
const SCROLL_MS = Number(process.env.SCROLL_MS || 3500)
const STEP = Number(process.env.STEP || 120)
const ROUNDS = Number(process.env.ROUNDS || 2)
const HEADED = process.env.HEADED === '1'
// 场景顺序固定:idle/scroll 在前(编辑态会污染后续场景),edit 永远最后
const SCENES = (process.env.SCENES || 'idle,scroll,scroll-hidden,edit').split(',')
const chromePath =
process.env.CHROME_PATH ||
'C:/Program Files/Google/Chrome/Application/chrome.exe'
const seedStorage = `
sessionStorage.setItem('fms-login', JSON.stringify({
loginInfo: { token: 'probe-token', user: { id: 1, account: 'admin', name: '探针用户' } },
}));
localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'PROBE', account: 'admin' } }));
localStorage.setItem('app', JSON.stringify({ darkMode: false }));
`
async function measure(page, { scene, durationMs }) {
// 编辑场景:用 Playwright 真实鼠标点击(带真实坐标),StkTable 选区需要真实事件
if (scene === 'edit') {
const rect = await page.evaluate(() => {
const root = document.querySelector('.fms-table')
const trs = root.querySelectorAll('.stk-tbody-main tr')
// 跳过序号列(data-col-key="")与 vt-x 占位列,点真实数据列
const td = [...trs[1]?.querySelectorAll('td[data-col-key]')].find(
(el) => el.getAttribute('data-col-key') !== '',
)
if (!td) return null
const r = td.getBoundingClientRect()
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
})
if (!rect) throw new Error('no target td for edit')
await page.mouse.click(rect.x, rect.y)
await page.waitForTimeout(500)
const mounted = await page.evaluate(
() =>
document.querySelectorAll(
'.fms-cell-editor-root input:not([type=checkbox]):not([type=hidden]), .fms-cell-editor-root textarea, .fms-cell-editor-root .select',
).length > 0,
)
if (!mounted) throw new Error('editor did not mount after real click')
}
return page.evaluate(
async ({ scene, durationMs, step }) => {
const root = document.querySelector('.fms-table')
if (!root) throw new Error('no .fms-table')
const els = [...root.querySelectorAll('*')]
const scrollers = els.filter(
(el) => el.scrollHeight > el.clientHeight + 2 && getComputedStyle(el).overflowY !== 'hidden',
)
const scroller = scrollers.sort(
(a, b) => b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight),
)[0]
if (!scroller) throw new Error('no vertical scroller')
const maxScroll = scroller.scrollHeight - scroller.clientHeight
const driveScroll = scene === 'scroll' || scene === 'scroll-hidden' || scene === 'edit'
// scroll-hidden:跳过 paint(保留布局与 JS 链路),隔离渲染 vs JS 开销
if (scene === 'scroll-hidden') {
root.style.visibility = 'hidden'
}
scroller.scrollTop = 0
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
if (driveScroll) {
for (let i = 0; i < 60; i++) {
scroller.scrollTop = (scroller.scrollTop + step) % (maxScroll + 1)
await new Promise((r) => requestAnimationFrame(r))
}
scroller.scrollTop = 0
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
}
let frames = 0
let minGap = Infinity
let maxGap = 0
let prev = performance.now()
const start = performance.now()
await new Promise((resolve) => {
const tick = (now) => {
const gap = now - prev
prev = now
frames++
if (gap < minGap) minGap = gap
if (gap > maxGap) maxGap = gap
if (driveScroll) {
if (scroller.scrollTop + step < maxScroll) scroller.scrollTop += step
else scroller.scrollTop = 0
}
if (now - start < durationMs) requestAnimationFrame(tick)
else resolve()
}
requestAnimationFrame(tick)
})
const elapsed = (performance.now() - start) / 1000
const fps = frames / elapsed
const editorCount = root.querySelectorAll(
'.fms-cell-editor-root input:not([type=checkbox]):not([type=hidden]), .fms-cell-editor-root textarea, .fms-cell-editor-root .select',
).length
const displayCount = root.querySelectorAll('.fms-cell-display').length
const nativeCellCount = root.querySelectorAll('.stk-tbody-main .table-cell-wrapper').length
return {
scene,
fps: Math.round(fps * 10) / 10,
avgFrameMs: Math.round(((elapsed * 1000) / frames) * 10) / 10,
minGapMs: Math.round(minGap),
maxGapMs: Math.round(maxGap),
renderedRows: root.querySelectorAll('.stk-tbody-main tr').length,
editorCount,
displayCount,
nativeCellCount,
maxScroll: Math.round(maxScroll),
}
},
{ scene, durationMs, step: STEP },
)
}
const browser = await chromium.launch({
executablePath: chromePath,
headless: !HEADED,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu-sandbox',
'--disable-background-timer-throttling',
],
})
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
await context.addInitScript(seedStorage)
const page = await context.newPage()
page.on('pageerror', (err) => console.log('[pageerror]', String(err).slice(0, 300)))
console.log(`打开 ${BASE}/dashboard ... (headless=${!HEADED})`)
await page.goto(`${BASE}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30000 })
await page.waitForSelector('.fms-table', { timeout: 20000 })
await page.waitForFunction(() => document.querySelectorAll('.stk-tbody-main tr').length > 0)
await page.waitForTimeout(1200)
console.log(`页面: ${await page.title()} | ${page.url()}`)
// 空页面 rAF 基准(headless 上限)
try {
const base = await page.evaluate(async ({ durationMs }) => {
let frames = 0
const start = performance.now()
await new Promise((resolve) => {
const tick = (now) => {
frames++
if (now - start < durationMs) requestAnimationFrame(tick)
else resolve()
}
requestAnimationFrame(tick)
})
return { fps: Math.round((frames / ((performance.now() - start) / 1000)) * 10) / 10 }
}, { durationMs: 2000 })
console.log(`[同页 rAF 上限基准] FPS=${base.fps}`)
} catch (e) {
console.log('rAF 基准跳过:', String(e).slice(0, 120))
}
const results = {}
for (let round = 1; round <= ROUNDS; round++) {
console.log(`\n===== 第 ${round}/${ROUNDS} 轮 =====`)
for (const scene of SCENES) {
const r = await measure(page, { scene, durationMs: SCROLL_MS })
const tag = {
idle: '静止',
scroll: '滚动',
'scroll-hidden': '滚动(隐藏paint)',
edit: '编辑中滚动',
}[scene] || scene
console.log(
`[${tag}] FPS=${r.fps} avgFrame=${r.avgFrameMs}ms minGap=${r.minGapMs} maxGap=${r.maxGapMs} ` +
`rows=${r.renderedRows} 原生格=${r.nativeCellCount} 编辑器=${r.editorCount} 展示格=${r.displayCount}`,
)
;(results[scene] ??= []).push(r.fps)
// 恢复可见性并回顶
await page.evaluate(() => {
const root = document.querySelector('.fms-table')
if (root) root.style.visibility = ''
const els = root ? [...root.querySelectorAll('*')] : []
const sc = els
.filter((el) => el.scrollHeight > el.clientHeight + 2)
.sort((a, b) => b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight))[0]
if (sc) sc.scrollTop = 0
})
await page.waitForTimeout(300)
}
// 每轮结束重载页面,清掉编辑态/滚动位置,避免污染下一轮
if (round < ROUNDS) {
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('.fms-table', { timeout: 20000 })
await page.waitForFunction(() => document.querySelectorAll('.stk-tbody-main tr').length > 0)
await page.waitForTimeout(1200)
}
}
console.log('\n===== 汇总(FPS) =====')
for (const [scene, list] of Object.entries(results)) {
const avg = list.reduce((a, b) => a + b, 0) / list.length
const tag = {
idle: '静止',
scroll: '滚动',
'scroll-hidden': '滚动(隐藏paint)',
edit: '编辑中滚动',
}[scene] || scene
console.log(`${tag}: ${list.join(' / ')} → 平均 ${Math.round(avg * 10) / 10}`)
}
await browser.close()