import { chromium } from '@playwright/test' /** * /table-test 性能探针 v5 —— 归因(只读诊断,不改仓库代码)。 * * 已确认事实(v3/v4): * · 静止 61 FPS;纵向滚动 ~11 FPS;横向滚动 ~10 FPS;编辑态纵向 ~8 FPS * · 点击格 → 编辑器就绪:中位 139ms,p95 444ms * · 去掉全部 editor(零 customCell)后纵向仍只有 17 FPS → 底座自身也有地板 * · 1000 行与 5000 行帧率几乎相同 → 与总行数无关(O(可见格)) * * 本版要回答: * Q1 每帧的 74~90ms 里,JS(Script) 与 样式/布局(Style/Layout/Paint) 各占多少?→ CDP Performance 指标 * Q2 横向滚动时到底有没有发生「本不该发生的」DOM 变更?→ MutationObserver 计数 * Q3 CSS 层里哪一条最贵?→ 注入 !important 覆盖做 A/B(背景渐变 / box-shadow / sticky) * Q4 点击一次编辑,钱花在哪几步?→ 分阶段打点 * Q5 页面 errorCount(1000 行 × 21 列)重算一次要多久? */ const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090' const MENU_CODE = 'menu.table_test' const INST = `(() => { let el = document.querySelector('.stk-table') let inst = el && (el.__vueParentComponent || null) return inst || null })()` async function makePage() { 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`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 90000 }) await page.waitForTimeout(2500) return { browser, context, page } } /** CDP Performance 指标:分离 JS 与 样式/布局 */ async function metricsDuring(page, context, fn) { const client = await context.newCDPSession(page) await client.send('Performance.enable') const pick = (m) => { const o = {} for (const x of m.metrics) o[x.name] = x.value return o } const before = pick(await client.send('Performance.getMetrics')) await fn() const after = pick(await client.send('Performance.getMetrics')) await client.detach() const d = (k) => +((after[k] ?? 0) - (before[k] ?? 0)).toFixed(3) return { TaskDuration: d('TaskDuration'), ScriptDuration: d('ScriptDuration'), StyleDuration: d('RecalcStyleDuration'), LayoutDuration: d('LayoutDuration'), RecalcStyleCount: d('RecalcStyleCount'), LayoutCount: d('LayoutCount'), Nodes: d('Nodes'), JSHeapMB: +((after.JSHeapUsedSize ?? 0) / 1048576).toFixed(1), } } /** 滚动(真实驱动 .stk-table) */ async function doScroll(page, { axis = 'y', steps = 120, px = 60 } = {}) { return page.evaluate( async ({ axis, steps, px }) => { const cont = document.querySelector('.stk-table') for (let i = 0; i < steps; i += 1) { await new Promise((r) => requestAnimationFrame(r)) 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) } } }, { axis, steps, px }, ) } async function fpsOf(page, args) { 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) for (let i = 0; i < steps; i += 1) { await new Promise((r) => requestAnimationFrame(r)) 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) } } 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(1), } }, args, ) } console.log('================= 打开页面 =================') const { browser, context, page } = await makePage() /* ---------- Q5: 初始挂载成本(errorCount 全表校验) ---------- */ const mount = await page.evaluate((src) => { const pageInst = (() => { const el = document.querySelector('.booking') let i = el?.__vueParentComponent while (i && !i.setupState?.rows) i = i.parent return i })() const out = { found: !!pageInst } if (pageInst) { const ss = pageInst.setupState const rows = ss.rows?.value ?? ss.rows const cols = ss.columns?.value ?? ss.columns out.rows = rows?.length out.cols = cols?.length // 计时:把 errorCount 弄脏后重读,等于跑一遍「1000 行 × 21 列」的校验 const t0 = performance.now() const v = ss.errorCount?.value out.errorCountCachedReadMs = +(performance.now() - t0).toFixed(2) out.errorCount = v // 弄脏一行 → 重算 if (rows?.length) { rows[0].freight = Number(rows[0].freight) + 0.001 const t1 = performance.now() void ss.errorCount?.value out.errorCountRecomputeMs = +(performance.now() - t1).toFixed(2) rows[0].freight = Number(rows[0].freight) - 0.001 } } return out }, INST) console.log('Q5 页面状态/errorCount 重算:', JSON.stringify(mount)) /* ---------- Q1+Q2: 纵向滚动 —— JS vs 样式布局 ---------- */ console.log('\n================= Q1 纵向滚动:JS vs 样式/布局 =================') const yMetrics = await metricsDuring(page, context, () => doScroll(page, { axis: 'y', steps: 120 })) console.log('纵向 120 帧指标:', JSON.stringify(yMetrics)) console.log( ` → 每帧:Script ${(yMetrics.ScriptDuration / 120 * 1000).toFixed(1)}ms | Style ${(yMetrics.StyleDuration / 120 * 1000).toFixed(1)}ms | Layout ${(yMetrics.LayoutDuration / 120 * 1000).toFixed(1)}ms | 重算样式 ${(yMetrics.RecalcStyleCount / 120).toFixed(1)} 次/帧 | 布局 ${(yMetrics.LayoutCount / 120).toFixed(1)} 次/帧`, ) /* ---------- Q2: 横向滚动期间的 DOM 变更计数 ---------- */ console.log('\n================= Q2 横向滚动:本不该发生的 DOM 变更 =================') const mutX = await page.evaluate(async () => { const cont = document.querySelector('.stk-table') const tbody = document.querySelector('.stk-tbody-main') let childList = 0 let attrs = 0 let charData = 0 const changedAttrs = {} const obs = new MutationObserver((records) => { for (const r of records) { if (r.type === 'childList') childList += r.addedNodes.length + r.removedNodes.length else if (r.type === 'attributes') { attrs += 1 changedAttrs[r.attributeName] = (changedAttrs[r.attributeName] || 0) + 1 } else charData += 1 } }) obs.observe(tbody, { childList: true, subtree: true, attributes: true, characterData: true }) const FRAMES = 60 for (let i = 0; i < FRAMES; i += 1) { await new Promise((r) => requestAnimationFrame(r)) const max = cont.scrollWidth - cont.clientWidth cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + 60) } await new Promise((r) => setTimeout(r, 50)) obs.disconnect() return { FRAMES, childList, attrs, charData, changedAttrs } }) console.log('横向 60 帧 tbody 变更:', JSON.stringify(mutX)) console.log( ` → 每帧:节点增删 ${(mutX.childList / mutX.FRAMES).toFixed(1)} | 属性写入 ${(mutX.attrs / mutX.FRAMES).toFixed(1)}`, ) /* ---------- Q2b: 纵向滚动 DOM 变更 ---------- */ const mutY = await page.evaluate(async () => { const cont = document.querySelector('.stk-table') const tbody = document.querySelector('.stk-tbody-main') let childList = 0 let attrs = 0 const obs = new MutationObserver((records) => { for (const r of records) { if (r.type === 'childList') childList += r.addedNodes.length + r.removedNodes.length else if (r.type === 'attributes') attrs += 1 } }) obs.observe(tbody, { childList: true, subtree: true, attributes: true, characterData: true }) const FRAMES = 60 for (let i = 0; i < FRAMES; i += 1) { await new Promise((r) => requestAnimationFrame(r)) const max = cont.scrollHeight - cont.clientHeight cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60) } await new Promise((r) => setTimeout(r, 50)) obs.disconnect() return { FRAMES, childList, attrs } }) console.log('纵向 60 帧 tbody 变更:', JSON.stringify(mutY)) console.log( ` → 每帧:节点增删 ${(mutY.childList / mutY.FRAMES).toFixed(1)} | 属性写入 ${(mutY.attrs / mutY.FRAMES).toFixed(1)}`, ) /* ---------- Q3: CSS 层归因(注入覆盖做 A/B) ---------- */ console.log('\n================= Q3 CSS 归因(!important 覆盖 A/B)=================') console.log('基线 纵向:', JSON.stringify(await fpsOf(page, { axis: 'y', steps: 100, px: 60 }))) console.log('基线 横向:', JSON.stringify(await fpsOf(page, { axis: 'x', steps: 80, px: 80 }))) async function cssAB(label, css, measAxis = 'y') { await page.addStyleTag({ content: css }) await page.waitForTimeout(500) const y = await fpsOf(page, { axis: 'y', steps: 100, px: 60 }) const x = await fpsOf(page, { axis: 'x', steps: 80, px: 80 }) console.log(`${label} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`) // 移除刚注入的样式 await page.evaluate(() => { const tags = [...document.querySelectorAll('style')] const last = tags[tags.length - 1] if (last && last.dataset.probe) last.remove() }) await page.waitForTimeout(300) return { y, x } } // 用一个带标记的注入函数,保证能精确移除 async function cssAB2(label, css) { await page.evaluate((c) => { const s = document.createElement('style') s.dataset.probe = '1' s.textContent = c document.head.appendChild(s) }, css) await page.waitForTimeout(500) const y = await fpsOf(page, { axis: 'y', steps: 100, px: 60 }) const x = await fpsOf(page, { axis: 'x', steps: 80, px: 80 }) console.log(`${label} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`) await page.evaluate(() => document.querySelector('style[data-probe]')?.remove()) await page.waitForTimeout(300) return { y, x } } await cssAB2('① 去掉单元格背景渐变(格线)', ` .stk-table td, .stk-table th { background-image: none !important; } `) await cssAB2('② 去掉全部 box-shadow(含 100vmax 叠色)', ` .stk-table td, .stk-table th, .stk-table tr { box-shadow: none !important; } `) await cssAB2('③ 去掉 sticky 固定列定位', ` .stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; } `) await cssAB2('④ 去掉单元格 padding/文字溢出省略', ` .stk-table td, .stk-table th { padding: 0 !important; } .stk-table .table-cell-wrapper { overflow: visible !important; text-overflow: clip !important; white-space: normal !important; } `) await cssAB2('⑤ ①②③④ 全上', ` .stk-table td, .stk-table th { background-image: none !important; box-shadow: none !important; } .stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; } .stk-table td, .stk-table th { padding: 0 !important; } .stk-table .table-cell-wrapper { overflow: visible !important; text-overflow: clip !important; white-space: normal !important; } `) /* ---------- Q4: 点击一次编辑的分阶段耗时 ---------- */ console.log('\n================= Q4 点击进入编辑:分阶段 =================') const clickPhases = await page.evaluate(async () => { const cont = document.querySelector('.stk-table') cont.scrollTop = 0 await new Promise((r) => requestAnimationFrame(r)) const out = [] const trs = [...document.querySelectorAll('.stk-tbody-main tr')].slice(1, 9) for (const tr of trs) { const td = [...tr.querySelectorAll('td')][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() // 阶段 1:派发点击 → 事件同步处理完 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 }), ) } const tDispatch = performance.now() - t0 // 阶段 2:等 Vue 把编辑器插入 DOM(rAF 轮询) let tRender = -1 const deadline = performance.now() + 3000 while (performance.now() < deadline) { if (td.querySelector('.g3-table__cell-editor-root')) { tRender = performance.now() - t0 break } await new Promise((r) => requestAnimationFrame(r)) } // 阶段 3:再等一帧,让布局/绘制稳定 await new Promise((r) => requestAnimationFrame(r)) const tStable = performance.now() - t0 out.push({ dispatchMs: +tDispatch.toFixed(1), renderDoneMs: +tRender.toFixed(1), stableMs: +tStable.toFixed(1), }) await new Promise((r) => setTimeout(r, 150)) } return out }) console.log('点击分阶段(ms):') for (const p of clickPhases) console.log(' ', JSON.stringify(p)) const med = (arr) => { const s = [...arr].sort((a, b) => a - b) return s.length ? +s[Math.floor(s.length / 2)].toFixed(1) : null } console.log( ` 中位:事件处理 ${med(clickPhases.map((p) => p.dispatchMs))}ms | 编辑器进 DOM ${med(clickPhases.map((p) => p.renderDoneMs))}ms | 稳定 ${med(clickPhases.map((p) => p.stableMs))}ms`, ) /* ---------- Q4b: 点同一格 vs 换格 ---------- */ console.log('\n================= Q4b 同格重复点击(隔离「首次挂载」成本)=================') const repeat = await page.evaluate(async () => { const cont = document.querySelector('.stk-table') cont.scrollTop = 0 await new Promise((r) => requestAnimationFrame(r)) const td = [...document.querySelectorAll('.stk-tbody-main tr')][3]?.querySelectorAll('td')[6] if (!td) return { error: 'no td' } const r = td.getBoundingClientRect() const cx = r.left + r.width / 2 const cy = r.top + r.height / 2 const fire = () => { 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 }), ) } } const timings = [] for (let i = 0; i < 6; i += 1) { const t0 = performance.now() fire() let done = -1 const deadline = performance.now() + 3000 while (performance.now() < deadline) { if (td.querySelector('.g3-table__cell-editor-root')) { done = performance.now() - t0 break } await new Promise((r) => requestAnimationFrame(r)) } timings.push(+done.toFixed(1)) await new Promise((r) => setTimeout(r, 200)) } return { timings, editorRootsNow: document.querySelectorAll('.g3-table__cell-editor-root').length } }) console.log('同格连点 6 次(ms):', JSON.stringify(repeat)) /* ---------- Q4c: 编辑态下的每键成本 ---------- */ console.log('\n================= Q4c 编辑态:敲一个字符的延迟 =================') const keyCost = await page.evaluate(async () => { const input = document.querySelector('.g3-table__cell-editor-root input') if (!input) return { error: 'no input (该列可能不是 text)' } const timings = [] for (let i = 0; i < 8; i += 1) { const t0 = performance.now() input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })) input.value = `${input.value}a` input.dispatchEvent(new Event('input', { bubbles: true })) await new Promise((r) => requestAnimationFrame(r)) timings.push(+(performance.now() - t0).toFixed(1)) await new Promise((r) => setTimeout(r, 120)) } return { timings } }) console.log('每键 → 下一帧(ms):', JSON.stringify(keyCost)) /* ---------- 编辑态滚动再测一次 + 指标 ---------- */ console.log('\n================= 编辑态:纵向滚动 + 指标 =================') const em = await metricsDuring(page, context, () => doScroll(page, { axis: 'y', steps: 100 })) const efps = await fpsOf(page, { axis: 'y', steps: 100, px: 60 }) console.log('编辑态 纵向:', JSON.stringify(efps)) console.log('编辑态 100 帧指标:', JSON.stringify(em)) const edDom = await page.evaluate(() => ({ editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length, inputs: document.querySelectorAll('.g3-table__cell-editor-root.g3-input').length, selects: document.querySelectorAll('.g3-table__cell-editor-root.g3-select').length, tds: document.querySelectorAll('.stk-tbody-main td').length, })) console.log('编辑态 DOM:', JSON.stringify(edDom)) await browser.close()