import { chromium } from '@playwright/test' /** * /table-test 探针 v10 —— 决定性归因(只读诊断,不改仓库代码)。 * * 三个关键实验: * * ① 冻结克隆对照(最关键) * 把当前 `.stk-table` 的 outerHTML 克隆一份、塞进同样尺寸的容器,然后滚**克隆**。 * 克隆不受 Vue 管理 —— 滚动它不触发任何 JS 重渲染。 * · 克隆也慢 → 瓶颈在浏览器侧(样式/布局/绘制/合成) * · 克隆很快 → 瓶颈在 Vue/库的 JS 重渲染 * * ② LoAF 完整字段(含 forcedStyleAndLayoutDuration / invoker / 暂停时长) * * ③ 数据量对照再确认:100 行(虚拟滚动关闭,无逐帧 DOM 改动)vs 1000 行(虚拟开启) * * ④ errorCount:一次真实「编辑提交」会额外付多少主线程时间 */ 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].sort((x, y) => x - y) return s.length ? s[Math.floor(s.length / 2)] : null } const r1 = (n) => (n == null ? null : +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) /** 通用滚动驱动:对指定选择器('live' | 'clone')滚动并测帧率 */ async function drive(which, { axis = 'y', steps = 60, px = 60 } = {}) { return page.evaluate( async ({ which, axis, steps, px }) => { const sel = which === 'live' ? '#__probe_live .stk-table' : '#__probe_clone .stk-table' const cont = document.querySelector(sel) if (!cont) return { error: 'missing ' + sel } 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), scrollH: cont.scrollHeight, clientH: cont.clientHeight, scrollW: cont.scrollWidth, clientW: cont.clientWidth, } }, { which, axis, steps, px }, ) } /* ============================================================ * ① 冻结克隆对照 * ============================================================ */ console.log('################ ① 冻结克隆对照 ################') const setup = await page.evaluate(() => { const live = document.querySelector('.stk-table') if (!live) return { error: 'no live table' } const r = live.getBoundingClientRect() // 给 live 包一个稳定 id 便于选择 live.id = '__probe_live_inner' if (!live.parentElement.id) live.parentElement.id = '__probe_live' // 克隆整棵子树(含 530 个 td / 全部 class),放进同样尺寸的容器 const clone = live.cloneNode(true) clone.id = '__probe_clone_inner' clone.removeAttribute('data-v-app') const holder = document.createElement('div') holder.id = '__probe_clone' holder.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;height:${r.height}px;z-index:99999;background:#fff;` holder.appendChild(clone) document.body.appendChild(holder) return { width: r.width, height: r.height, liveSel: '#__probe_live .stk-table', cloneSel: '#__probe_clone .stk-table', liveTds: live.querySelectorAll('tbody td').length, cloneTds: clone.querySelectorAll('tbody td').length, } }) console.log('克隆搭建:', JSON.stringify(setup)) // 隐藏克隆时先测 live(避免克隆覆盖带来的合成差异) await page.evaluate(() => { const h = document.querySelector('#__probe_clone') if (h) h.style.visibility = 'hidden' }) await page.waitForTimeout(300) const liveY = [] for (let i = 0; i < 3; i += 1) liveY.push(await drive('live', { axis: 'y', steps: 55, px: 60 })) await page.evaluate(() => { const h = document.querySelector('#__probe_clone') if (h) h.remove() }) console.log('live(Vue 管理,虚拟滚动逐帧改 DOM)纵向 ×3:', JSON.stringify(liveY.map((r) => r.fps))) /* --- 重建克隆:A) 原样克隆(含 sticky / 渐变 / class) --- */ async function cloneTest(label, mutateCss) { await page.evaluate((css) => { document.querySelector('#__probe_clone')?.remove() const live = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table') const r = live.getBoundingClientRect() const clone = live.cloneNode(true) clone.removeAttribute('id') const holder = document.createElement('div') holder.id = '__probe_clone' holder.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;height:${r.height}px;z-index:99999;background:#fff;` // 绝对定位的 mask:把 live 盖住(量的是可见区域的真实光栅化成本) holder.appendChild(clone) document.body.appendChild(holder) if (css) { const s = document.createElement('style') s.dataset.clone = '1' s.textContent = css document.head.appendChild(s) } }, mutateCss || '') await page.waitForTimeout(500) const runs = [] for (let i = 0; i < 3; i += 1) runs.push(await drive('clone', { axis: 'y', steps: 55, px: 60 })) const runsX = [] for (let i = 0; i < 2; i += 1) runsX.push(await drive('clone', { axis: 'x', steps: 45, px: 80 })) console.log( `${label}\n 纵 ${runs.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runs.map((r) => r.fps)))}) | 横 ${runsX.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runsX.map((r) => r.fps)))})`, ) await page.evaluate(() => { document.querySelectorAll('style[data-clone]').forEach((s) => s.remove()) document.querySelector('#__probe_clone')?.remove() }) await page.waitForTimeout(200) return { y: r1(median(runs.map((r) => r.fps))), x: r1(median(runsX.map((r) => r.fps))) } } console.log('\n--- 克隆(无 Vue 参与,纯浏览器滚动同一份 DOM)---') const cloneResult = await cloneTest('A) 原样克隆') await cloneTest( 'B) 克隆 + 去掉固定列 sticky', `#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; }`, ) await cloneTest( 'C) 克隆 + 去掉单元格背景渐变(格线)', `#__probe_clone td, #__probe_clone th { background-image: none !important; }`, ) await cloneTest( 'D) 克隆 + 去掉 sticky 与全部 box-shadow', `#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; } #__probe_clone td, #__probe_clone th, #__probe_clone tr { box-shadow: none !important; }`, ) /* ============================================================ * ② LoAF 完整字段 * ============================================================ */ console.log('\n################ ② LoAF 完整字段(纵向滚动)################') const loaf = await page.evaluate(async () => { const cont = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table') cont.scrollTop = 0 await new Promise((r) => requestAnimationFrame(r)) const entries = [] const obs = new PerformanceObserver((l) => { for (const e of l.getEntries()) entries.push(e) }) obs.observe({ type: 'long-animation-frame', buffered: false }) for (let i = 0; i < 45; 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, 250)) obs.disconnect() const scripts = [] const frames = [] for (const e of entries) { frames.push({ duration: +e.duration.toFixed(1), blocking: +(e.blockingDuration || 0).toFixed(1), renderStart: +(e.renderStart || 0).toFixed(1), styleLayoutStart: +(e.styleAndLayoutStart || 0).toFixed(1), scripts: (e.scripts || []).map((s) => ({ name: s.name, entryType: s.entryType, invoker: s.invoker, invokerType: s.invokerType, duration: +s.duration.toFixed(1), forcedStyleAndLayout: s.forcedStyleAndLayoutDuration != null ? +s.forcedStyleAndLayoutDuration.toFixed(1) : undefined, pause: s.pauseDuration != null ? +s.pauseDuration.toFixed(1) : undefined, url: (s.sourceURL || '').split('/').slice(-1)[0].split('?')[0], fn: s.sourceFunctionName, pos: s.sourceCharPosition, })), }) for (const s of e.scripts || []) scripts.push(s) } // 按脚本来源汇总 const byKey = new Map() for (const s of scripts) { const key = `${s.name || s.invoker} | ${(s.sourceURL || '').split('/').slice(-1)[0].split('?')[0]} | fn=${s.sourceFunctionName || '-'} | pos=${s.sourceCharPosition} | invokerType=${s.invokerType}` const cur = byKey.get(key) || { total: 0, n: 0, forced: 0, pause: 0 } cur.total += s.duration cur.n += 1 cur.forced += s.forcedStyleAndLayoutDuration || 0 cur.pause += s.pauseDuration || 0 byKey.set(key, cur) } return { loafCount: frames.length, totalLoafMs: +frames.reduce((a, f) => a + f.duration, 0).toFixed(0), totalBlockingMs: +frames.reduce((a, f) => a + f.blocking, 0).toFixed(0), sampleFrames: frames.slice(0, 3), scriptSources: [...byKey.entries()] .map(([k, v]) => ({ key: k, totalMs: +v.total.toFixed(1), calls: v.n, forcedStyleLayoutMs: +v.forced.toFixed(1), pauseMs: +v.pause.toFixed(1), })) .sort((a, b) => b.totalMs - a.totalMs) .slice(0, 8), } }) console.log(JSON.stringify(loaf, null, 2)) /* ============================================================ * ③ 数据量对照(确认「逐帧 DOM 改动」是否为主因) * ============================================================ */ console.log('\n################ ③ 数据量对照:虚拟滚动开/关 ################') for (const n of [10, 100, 1000]) { await page.selectOption('.booking__select', String(n)) await page.waitForTimeout(2200) const info = await page.evaluate(() => { const t = document.querySelector('.stk-table') return { cls: t.className.includes('vt-on') ? 'vt-on(虚拟开启)' : 'vt-off(全量渲染)', trs: document.querySelectorAll('.stk-tbody-main tr').length, tds: document.querySelectorAll('.stk-tbody-main td').length, scrollH: t.scrollHeight, clientH: t.clientHeight, } }) // 逐帧 DOM 改动计数 const churn = await page.evaluate(async () => { const tbody = document.querySelector('.stk-tbody-main') const cont = document.querySelector('.stk-table') let child = 0 let attrs = 0 const obs = new MutationObserver((recs) => { for (const m of recs) { if (m.type === 'childList') child += m.addedNodes.length + m.removedNodes.length else if (m.type === 'attributes') attrs += 1 } }) obs.observe(tbody, { childList: true, subtree: true, attributes: true }) const F = 40 for (let i = 0; i < F; 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, 60)) obs.disconnect() return { childPerFrame: +(child / F).toFixed(1), attrPerFrame: +(attrs / F).toFixed(1) } }) const y = await drive('live', { axis: 'y', steps: 50, px: 60 }) const x = await drive('live', { axis: 'x', steps: 40, px: 80 }) console.log( `${String(n).padStart(5)} 行 ${info.cls.padEnd(16)} tr=${String(info.trs).padStart(3)} td=${String(info.tds).padStart(4)} | 逐帧 节点${String(churn.childPerFrame).padStart(5)} 属性${String(churn.attrPerFrame).padStart(5)} | 纵 ${String(y.fps).padStart(5)}fps | 横 ${String(x.fps).padStart(5)}fps`, ) } /* ============================================================ * ④ errorCount:一次「编辑提交」的额外成本 * ============================================================ */ console.log('\n################ ④ 编辑提交 → errorCount 全表校验成本 ################') await page.selectOption('.booking__select', '1000') await page.waitForTimeout(2200) const commitCost = await page.evaluate(() => { 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 void ss.errorCount?.value // 预热 // 模拟「编辑提交」:写一个响应式行字段(editors 的 commitInputDraft 就是这么做的) const out = [] for (let k = 0; k < 5; k += 1) { const r = rows[k] const t0 = performance.now() r.remark = `${r.remark ?? ''}x` // 等 Vue 把 errorCount 重算 + 页面重渲染跑完 const t1 = performance.now() out.push({ writeMs: +(t1 - t0).toFixed(2) }) r.remark = String(r.remark).slice(0, -1) } return { rows: rows.length, out } }) console.log('提交写入本身:', JSON.stringify(commitCost)) // 直接量 errorCount 重算 const recompute = await page.evaluate(() => { let i = document.querySelector('.booking')?.__vueParentComponent while (i && !i.setupState?.rows) i = i.parent const ss = i.setupState const rows = ss.rows?.value ?? ss.rows const out = [] for (let k = 0; k < 3; k += 1) { void ss.errorCount?.value if (rows?.length) rows[0].freight = Number(rows[0].freight) + 0.001 const t0 = performance.now() void ss.errorCount?.value out.push(+(performance.now() - t0).toFixed(1)) if (rows?.length) rows[0].freight = Number(rows[0].freight) - 0.001 } return { rows: rows.length, cols: (ss.columns?.value ?? ss.columns)?.length, ms: out } }) console.log('errorCount 重算(1000 行 × 22 列):', JSON.stringify(recompute)) // 真实点击 → 输入 → 失焦提交 的端到端 const e2e = 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] // 找「备注」列(最右固定列) const tds = [...tr.querySelectorAll('td')] const td = tds[tds.length - 1] 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() + 3000 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 { toEditor: +toEditor.toFixed(1), error: 'no input' } input.focus() input.value = `${input.value}z` input.dispatchEvent(new Event('input', { bubbles: true })) const t1 = performance.now() input.blur() // 触发 commit + errorCount 重算 await new Promise((r2) => requestAnimationFrame(r2)) await new Promise((r2) => setTimeout(r2, 0)) return { toEditorMs: +toEditor.toFixed(1), commitMs: +(performance.now() - t1).toFixed(1), } }) console.log('端到端(点击→编辑器 / 失焦提交):', JSON.stringify(e2e)) await browser.close()