import { chromium } from '@playwright/test' /** * /table-test 性能探针 v4 —— 归因实验(只读诊断,不改业务代码)。 * * v3 已确认:静止 61 FPS,纵向滚动 ~13 FPS。本版要回答「74ms/帧花在哪」, * 用**受控对照**而不是继续猜: * * A 现状 基线 * B 去掉 validate/precision → 常驻 customCell 的列少 4 个 * C 去掉全部 editor → 一个 customCell 都不挂(性能上限参考) * D 数据量 10 / 100 / 1000 → 判断开销是 O(可见格) 还是 O(总行数) * * 说明:B/C 是在**探针浏览器里**原地改内存中的列定义,用于归因;不改仓库代码。 */ const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090' const MENU_CODE = 'menu.table_test' async function makePage(extraQuery = '') { 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${extraQuery}`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 90000 }) await page.waitForTimeout(2500) return { browser, context, page } } /** 滚动测量:真正驱动 .stk-table 的 scrollTop */ async function scrollFps(page, { axis = 'y', steps = 140, px = 60 } = {}) { 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) 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 { fps: +(1000 / avg).toFixed(1), avgMs: +avg.toFixed(2), p95Ms: +q(sorted, 0.95).toFixed(2), maxMs: +sorted[sorted.length - 1].toFixed(2), syncMs: +(sync.reduce((a, b) => a + b, 0) / sync.length).toFixed(2), } }, { axis, steps, px }, ) } /** 取 G3Table 实例(.g3-table 的宿主组件) */ const GET_INST = `(() => { const el = document.querySelector('.g3-table') let inst = el && (el.__vueParentComponent || el.__vue_vnode?.component) if (!inst) return null // 有时拿到的是父布局,向上找带 columns 的那个 let cur = inst, hop = 0 while (cur && hop < 8) { const ss = cur.setupState if (ss && (ss.columns || ss.rows)) return cur cur = cur.parent; hop++ } return inst })()` async function domInfo(page) { return page.evaluate(() => ({ bodyRows: document.querySelectorAll('.stk-tbody-main tr').length, tds: document.querySelectorAll('.stk-tbody-main td').length, cellWrappers: document.querySelectorAll('.stk-tbody-main td > .table-cell-wrapper').length, displaySpans: document.querySelectorAll('.g3-table__cell-display').length, editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length, })) } /* ============================================================ * 实验 A:现状基线 * ============================================================ */ console.log('\n########## A. 现状基线(1000 行)##########') { const { browser, page } = await makePage() console.log('DOM:', JSON.stringify(await domInfo(page))) console.log('纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' }))) console.log('横向滚动:', JSON.stringify(await scrollFps(page, { axis: 'x', steps: 100, px: 80 }))) await browser.close() } /* ============================================================ * 实验 B/C:削减 customCell(同页内原地改列定义) * ============================================================ */ console.log('\n########## B/C. 削减常驻 customCell 的影响 ##########') { const { browser, page } = await makePage() console.log('基线:', JSON.stringify(await scrollFps(page, { axis: 'y' }))) // B:去掉所有 validate / validateWarning / precision → 少 4 列常驻 customCell const bRes = await page.evaluate((getInstSrc) => { const inst = eval(getInstSrc) if (!inst) return { error: 'no instance' } const cols = inst.setupState.columns const arr = cols?.value ?? cols let n = 0 for (const c of arr) { if (!c.editor) continue if (c.editor.validate) { c.editor.validate = undefined; n++ } if (c.editor.validateWarning) { c.editor.validateWarning = undefined; n++ } if (c.editor.precision !== undefined) { c.editor.precision = undefined; n++ } } return { stripped: n, total: arr.length } }, GET_INST) console.log('B 去掉校验/精度:', JSON.stringify(bRes)) await page.waitForTimeout(1500) console.log('B DOM:', JSON.stringify(await domInfo(page))) console.log('B 纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' }))) // C:去掉全部 editor → 零 customCell(性能上限) const cRes = await page.evaluate((getInstSrc) => { const inst = eval(getInstSrc) if (!inst) return { error: 'no instance' } const cols = inst.setupState.columns const arr = cols?.value ?? cols let n = 0 for (const c of arr) { if (c.editor) { delete c.editor; n++ } } return { removed: n, total: arr.length } }, GET_INST) console.log('C 移除全部 editor:', JSON.stringify(cRes)) await page.waitForTimeout(2000) console.log('C DOM:', JSON.stringify(await domInfo(page))) console.log('C 纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' }))) console.log('C 横向滚动:', JSON.stringify(await scrollFps(page, { axis: 'x', steps: 100, px: 80 }))) await browser.close() } /* ============================================================ * 实验 D:数据量(用页面自带的下拉) * ============================================================ */ console.log('\n########## D. 数据量 → 帧率(判断 O(n) 还是 O(可见))##########') { const { browser, page } = await makePage() for (const n of [10, 100, 1000, 5000]) { await page.selectOption('.booking__select', String(n)) await page.waitForTimeout(2500) const info = await domInfo(page) const y = await scrollFps(page, { axis: 'y', steps: 120, px: 60 }) const x = await scrollFps(page, { axis: 'x', steps: 80, px: 80 }) console.log( `${String(n).padStart(5)} 行 | 行DOM=${String(info.bodyRows).padStart(3)} td=${String(info.tds).padStart(4)} span=${String(info.displaySpans).padStart(4)} | 纵 ${String(y.fps).padStart(5)}fps (${y.avgMs}ms) | 横 ${String(x.fps).padStart(5)}fps (${x.avgMs}ms)`, ) } await browser.close() } /* ============================================================ * 实验 E:固定列 sticky 补丁 A/B(组件支持的 URL 开关) * ============================================================ */ console.log('\n########## E. 固定列 sticky 补丁 A/B ##########') { for (const q of ['', '?g3-fixed-patch=off']) { const { browser, page } = await makePage(q) const y = await scrollFps(page, { axis: 'y', steps: 120, px: 60 }) const x = await scrollFps(page, { axis: 'x', steps: 80, px: 80 }) console.log(`patch${q ? ' OFF' : ' ON '} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`) await browser.close() } } /* ============================================================ * 实验 F:滚动期间的长任务 & 每帧样式重算 * ============================================================ */ console.log('\n########## F. 滚动期间长任务 ##########') { const { browser, page } = await makePage() const res = await page.evaluate(async () => { const tasks = [] const obs = new PerformanceObserver((list) => { for (const e of list.getEntries()) tasks.push(+e.duration.toFixed(1)) }) try { obs.observe({ entryTypes: ['longtask'] }) } catch { return { unsupported: true } } const cont = document.querySelector('.stk-table') for (let i = 0; i < 90; i += 1) { await new Promise((r) => requestAnimationFrame(r)) cont.scrollTop = (cont.scrollTop + 60) % (cont.scrollHeight - cont.clientHeight) } await new Promise((r) => setTimeout(r, 300)) obs.disconnect() return { count: tasks.length, avg: +(tasks.reduce((a, b) => a + b, 0) / (tasks.length || 1)).toFixed(1), max: tasks.length ? Math.max(...tasks) : 0, sample: tasks.slice(0, 12), } }) console.log('长任务(>50ms):', JSON.stringify(res)) await browser.close() }