import { chromium } from '@playwright/test' /** * /table-test 探针 v11 —— 最终确认(只读诊断,不改仓库代码)。 * * 已有结论(v1~v10): * · 静止 61fps;纵向 ~10-15fps;横向 ~18-28fps;编辑态纵向 ~8-11fps * · **同一份 DOM 的冻结克隆滚到 60fps** → 瓶颈在 JS 重渲染,不在 CSS/绘制 * · LoAF:滚动帧 ~90% 时间挂在一个 user-callback(库的 rAF) * · 首次点击 = 全表列重建(1008 次 DOM 变更)→ 300-500ms * * 本版要拿下的最后几个数字(都用交替多轮取中位,避免单轮方差): * ① 全部 editor 移除 vs 保留 → customCell 层占多少(纵向/横向) * ② 首次点击 / 换行点击 / 同格点击 的耗时与 DOM 变更量(含 thead 是否重建) * ③ 「保存明细」按钮(findFirstError 全表校验)在 1000 / 5000 行下的耗时 * ④ errorCount 在 1000 / 5000 行的重算耗时 + 一次提交的端到端延迟 */ const URL_BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090' const MENU_CODE = 'menu.table_test' const median = (a) => { const s = [...a].filter((x) => x != null).sort((x, y) => x - y) return s.length ? s[Math.floor(s.length / 2)] : null } const r1 = (n) => (n == null ? null : +Number(n).toFixed(1)) const browser = await chromium.launch({ headless: false }) 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(`${URL_BASE}/table-test`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 90000 }) await page.waitForTimeout(3000) async function scrollFps(page, { axis = 'y', steps = 50, px = 60 } = {}) { const ok = await page.evaluate(() => !!document.querySelector('.stk-table')) if (!ok) return { fps: null } return page.evaluate( async ({ axis, steps, px }) => { const cont = document.querySelector('.stk-table') cont.scrollTop = 0 cont.scrollLeft = 0 await new Promise((r) => requestAnimationFrame(r)) 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 = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] return { fps: +(1000 / avg).toFixed(1), avg: +avg.toFixed(1), p95: +q(0.95).toFixed(1) } }, { axis, steps, px }, ) } /* ============================================================ * ① editor 保留 vs 移除(交替 3 轮) * ============================================================ */ console.log('################ ① customCell(editor)层的开销 ################') // 把页面 columns 里的 editor 摘下来存好,便于来回切换 const setup = await page.evaluate(() => { let i = document.querySelector('.booking')?.__vueParentComponent while (i && !i.setupState?.columns) i = i.parent if (!i) return { error: 'no page instance' } const cols = i.setupState.columns const arr = cols?.value ?? cols // 存到 window,供后续 A/B window.__probeSaved = arr.map((c) => c.editor) window.__probeCols = arr return { cols: arr.length, withEditor: arr.filter((c) => c.editor).length } }) console.log('页面列:', JSON.stringify(setup)) async function setEditors(on) { await page.evaluate((on) => { const arr = window.__probeCols const saved = window.__probeSaved if (!arr) return arr.forEach((c, idx) => { if (on) { if (saved[idx]) c.editor = saved[idx] } else if (c.editor) { delete c.editor } }) }, on) await page.evaluate( () => new Promise((r) => { requestAnimationFrame(() => requestAnimationFrame(r)) }), ) await page.waitForTimeout(700) } const onY = [] const onX = [] const offY = [] const offX = [] for (let r = 0; r < 3; r += 1) { await setEditors(true) onY.push((await scrollFps(page, { axis: 'y', steps: 50, px: 60 })).fps) onX.push((await scrollFps(page, { axis: 'x', steps: 45, px: 80 })).fps) await setEditors(false) offY.push((await scrollFps(page, { axis: 'y', steps: 50, px: 60 })).fps) offX.push((await scrollFps(page, { axis: 'x', steps: 45, px: 80 })).fps) } await setEditors(true) const domOn = await page.evaluate(() => ({ editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length, displaySpans: document.querySelectorAll('.g3-table__cell-display').length, tds: document.querySelectorAll('.stk-tbody-main td').length, })) console.log(`保留全部 editor | 纵 ${r1(median(onY))}fps (${onY.join('/')}) | 横 ${r1(median(onX))}fps (${onX.join('/')})`) console.log(`移除全部 editor | 纵 ${r1(median(offY))}fps (${offY.join('/')}) | 横 ${r1(median(offX))}fps (${offX.join('/')})`) console.log('保留 editor 时 DOM:', JSON.stringify(domOn)) /* ============================================================ * ② 点击三种场景 + thead 是否重建 * ============================================================ */ console.log('\n################ ② 点击进入编辑:场景与 DOM 变更 ################') async function clickProbe(rowIdx, colIdx, settle = 350) { await page.waitForTimeout(settle) return page.evaluate( async ({ rowIdx, colIdx }) => { const tr = [...document.querySelectorAll('.stk-tbody-main tr')][rowIdx] if (!tr) return { error: 'no row' } const td = [...tr.querySelectorAll('td')][colIdx] if (!td) return { error: 'no td' } const r = td.getBoundingClientRect() if (r.top < 0 || r.bottom > window.innerHeight) return { error: 'offscreen' } const cx = r.left + r.width / 2 const cy = r.top + r.height / 2 const tbody = document.querySelector('.stk-tbody-main') const thead = document.querySelector('thead') let tbChild = 0 let tbAttr = 0 let thChild = 0 const obs = new MutationObserver((recs) => { for (const m of recs) { const isHead = thead.contains(m.target) if (m.type === 'childList') { if (isHead) thChild += m.addedNodes.length + m.removedNodes.length else tbChild += m.addedNodes.length + m.removedNodes.length } else if (m.type === 'attributes' && !isHead) tbAttr += 1 } }) obs.observe(document.querySelector('.stk-table'), { childList: true, subtree: true, attributes: true, }) 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 }), ) } const dispatchMs = +(performance.now() - t0).toFixed(2) let mounted = -1 const deadline = performance.now() + 4000 while (performance.now() < deadline) { if (td.querySelector('.g3-table__cell-editor-root')) { mounted = performance.now() - t0 break } await new Promise((r2) => requestAnimationFrame(r2)) } await new Promise((r2) => requestAnimationFrame(r2)) await new Promise((r2) => setTimeout(r2, 120)) obs.disconnect() return { dispatchMs, toEditorMs: +mounted.toFixed(1), tbChild, tbAttr, thChild, editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length, } }, { rowIdx, colIdx }, ) } // 重新载入以拿到「真正的首次点击」 await page.reload({ waitUntil: 'domcontentloaded' }) await page.waitForSelector('.stk-table', { timeout: 90000 }) await page.waitForTimeout(3000) const first = await clickProbe(3, 6, 500) console.log('A. 页面加载后首次点击:', JSON.stringify(first)) const rowsSwitch = [] for (const r of [6, 8, 10, 12]) rowsSwitch.push(await clickProbe(r, 6, 350)) console.log('B. 换行点击 ×4:', JSON.stringify(rowsSwitch.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr, thChild: x.thChild })))) const same = [] for (let i = 0; i < 3; i += 1) same.push(await clickProbe(12, 6, 250)) console.log('C. 同格连点 ×3:', JSON.stringify(same.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr })))) const colSwitch = [] for (const c of [2, 3, 5]) colSwitch.push(await clickProbe(12, c, 350)) console.log('D. 同行换列 ×3:', JSON.stringify(colSwitch.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr })))) /* ============================================================ * ③ 保存按钮(findFirstError 全表校验) * ============================================================ */ console.log('\n################ ③ 「保存明细」全表校验(findFirstError)################') for (const n of [1000, 5000]) { await page.selectOption('.booking__select', String(n)) await page.waitForTimeout(2600) const res = await page.evaluate(async () => { let i = document.querySelector('.booking')?.__vueParentComponent while (i && !i.setupState?.rows) i = i.parent if (!i) return { error: 'no page instance' } const ss = i.setupState const rows = ss.rows?.value ?? ss.rows const cols = ss.columns?.value ?? ss.columns // 直接跑一遍与 findFirstError/errorCount 等价的循环,量 O(rows×cols) 成本 const t0 = performance.now() let checks = 0 for (let a = 0; a < rows.length; a += 1) { const row = rows[a] for (let b = 0; b < cols.length; b += 1) { const col = cols[b] if (!col.editor) continue checks += 1 const precision = typeof col.editor.precision === 'function' ? col.editor.precision(row) : col.editor.precision void (col.editor.validate?.(row[col.dataIndex], row, col) || precision) } } const loopMs = performance.now() - t0 // errorCount 强制重算 void ss.errorCount?.value rows[0].freight = Number(rows[0].freight) + 0.001 const t1 = performance.now() const ec = ss.errorCount?.value const errorCountMs = performance.now() - t1 rows[0].freight = Number(rows[0].freight) - 0.001 // 真实点「保存明细」按钮的端到端 const btn = [...document.querySelectorAll('.booking__btn')].find((b) => b.textContent.includes('保存明细'), ) const t2 = performance.now() btn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await new Promise((r2) => requestAnimationFrame(r2)) await new Promise((r2) => setTimeout(r2, 0)) const saveMs = performance.now() - t2 return { rows: rows.length, cols: cols.length, editorCols: cols.filter((c) => c.editor).length, checks, loopMs: +loopMs.toFixed(1), errorCountMs: +errorCountMs.toFixed(1), errorCount: ec, saveClickMs: +saveMs.toFixed(1), } }) console.log(` ${JSON.stringify(res)}`) } /* ============================================================ * ④ 提交一次编辑的端到端 + errorCount * ============================================================ */ console.log('\n################ ④ 一次编辑提交的端到端延迟 ################') for (const n of [1000, 5000]) { await page.selectOption('.booking__select', String(n)) await page.waitForTimeout(2600) const res = await page.evaluate(async () => { const cont = document.querySelector('.stk-table') cont.scrollTop = 0 await new Promise((r) => requestAnimationFrame(r)) const tr = [...document.querySelectorAll('.stk-tbody-main tr')][2] if (!tr) return { error: 'no row' } const tds = [...tr.querySelectorAll('td')] const td = tds[tds.length - 1] // 备注列(最右固定列,text editor) const r = td.getBoundingClientRect() 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 }), ) } let toEditor = -1 const deadline = performance.now() + 4000 while (performance.now() < deadline) { if (td.querySelector('.g3-table__cell-editor-root')) { toEditor = performance.now() - t0 break } await new Promise((r2) => requestAnimationFrame(r2)) } const input = td.querySelector('input') if (!input) return { toEditorMs: +toEditor.toFixed(1), error: 'no input' } input.focus() const t1 = performance.now() input.value = `${input.value}Z` input.dispatchEvent(new Event('input', { bubbles: true })) const typeMs = performance.now() - t1 const t2 = performance.now() input.blur() await new Promise((r2) => requestAnimationFrame(r2)) await new Promise((r2) => setTimeout(r2, 0)) await new Promise((r2) => requestAnimationFrame(r2)) return { toEditorMs: +toEditor.toFixed(1), typeMs: +typeMs.toFixed(1), commitMs: +(performance.now() - t2).toFixed(1), } }) console.log(` ${n} 行: ${JSON.stringify(res)}`) } await browser.close()