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()