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