import { chromium } from '@playwright/test' /** * 探针 v14 —— 验证辩论中提出的两个质疑(只读诊断,不改仓库代码)。 * * 来自辩论(甲方裁决)的两条质疑,都需要实测判定: * * Q1「克隆实验分不清两层:Vue vnode diff 成本 vs 底座逐帧写 class/style 成本」 * → 实验:在**克隆**(无 Vue、无底座逻辑)上,用 setInterval/rAF 逐帧给全部 td * 写内联样式(模拟 Vue patchStyle 的成本),看帧率是否掉下来。 * · 掉下来 → 「逐帧 DOM/样式写入」本身就是主因(且与 Vue 无关) * · 不掉 → 主因在 Vue vnode diff 那一层 * * Q2「useVirtualScroll.ts:824 早退说明窗口不变的帧零重渲染,所以 * '每帧 12.8 节点变更' 与早退矛盾、且我用了过大的滚动步长」 * → 实验:扫描滚动步长(小步 = 真实滚轮,大步 = 我之前的测法), * 量每步的窗口是否变化、节点变更量与帧率。 * rowHeight=28,步长 < 28px 时 startIndex 可能不变 → 早退 → 零重渲染。 */ 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 res = await route.fetch() if (viewName !== 's_power' && viewName !== 's_user_power') return route.fulfill({ response: res }) let json try { json = await res.json() } catch { return route.fulfill({ response: res }) } 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: res, body: JSON.stringify(json), headers: { ...res.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) /* 读出真实的 rowHeight 与虚拟参数,供 Q2 判断 */ const cfg = await page.evaluate(() => { const live = document.querySelector('.stk-table') return { rowHeightCssVar: getComputedStyle(live).getPropertyValue('--row-height').trim(), trs: document.querySelectorAll('.stk-tbody-main tr').length, tds: document.querySelectorAll('.stk-tbody-main td').length, scrollH: live.scrollHeight, clientH: live.clientHeight, } }) console.log('表格几何:', JSON.stringify(cfg)) /* ============================================================ * Q2:滚动步长扫描 —— 窗口变化频率 vs 帧率 * ============================================================ */ console.log('\n################ Q2 滚动步长扫描(rowHeight=28px)################') async function stepSweep(px, rounds = 2) { const runs = [] for (let r = 0; r < rounds; r += 1) { const out = await page.evaluate( async ({ px }) => { const cont = document.querySelector('.stk-table') const tbody = document.querySelector('.stk-tbody-main') cont.scrollTop = 0 await new Promise((r2) => requestAnimationFrame(r2)) let child = 0 let attr = 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') attr += 1 } }) obs.observe(tbody, { childList: true, subtree: true, attributes: true }) // 记录每步的「首行 data-row-i」以判断窗口是否真的换了 const windowSeq = [] 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 STEPS = 40 for (let i = 0; i < STEPS; i += 1) { await new Promise((r2) => requestAnimationFrame(r2)) const before = tbody.querySelector('tr')?.getAttribute('data-row-i') const max = cont.scrollHeight - cont.clientHeight cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + px) await new Promise((r2) => requestAnimationFrame(r2)) const after = tbody.querySelector('tr')?.getAttribute('data-row-i') windowSeq.push(before !== after ? 1 : 0) } stop = true await new Promise((r2) => setTimeout(r2, 60)) obs.disconnect() 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), windowChangedPerStep: +(windowSeq.reduce((a, b) => a + b, 0) / windowSeq.length).toFixed(2), childPerStep: +(child / STEPS).toFixed(1), attrPerStep: +(attr / STEPS).toFixed(1), } }, { px }, ) runs.push(out) } const m = (k) => r1(median(runs.map((x) => x[k]))) console.log( `步长 ${String(px).padStart(3)}px (${(px / 28).toFixed(2)} 行) | ${String(m('fps')).padStart(5)}fps (${m('avg')}ms) | 窗口变化 ${m('windowChangedPerStep')}/步 | 节点 ${String(m('childPerStep')).padStart(5)}/步 | 属性 ${String(m('attrPerStep')).padStart(5)}/步`, ) return { px, fps: m('fps'), windowChangedPerStep: m('windowChangedPerStep'), childPerStep: m('childPerStep') } } const sweep = [] for (const px of [4, 12, 24, 28, 60, 120]) sweep.push(await stepSweep(px)) console.log('\n结论线索:窗口变化率 <1 表示有若干步触发了 824 行的早退(零重渲染)') /* ============================================================ * Q1:克隆 + 逐帧样式写入(分离 Vue diff 与 DOM/样式写入) * ============================================================ */ console.log('\n################ Q1 克隆 + 逐帧内联样式写入 ################') /** 建一个克隆,可选用 rAF 逐帧给全部 td 写内联样式 */ async function cloneTest(label, { writeStyle = false, tdCount = 0 } = {}) { await page.evaluate( ({ writeStyle }) => { // 清理旧克隆与旧定时器 document.querySelector('#__pc')?.remove() if (window.__pcRaf) { cancelAnimationFrame(window.__pcRaf) window.__pcRaf = null } const live = document.querySelector('.stk-table') const r = live.getBoundingClientRect() const clone = live.cloneNode(true) const holder = document.createElement('div') holder.id = '__pc' 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) if (writeStyle) { const tds = [...clone.querySelectorAll('tbody td')] let i = 0 const loop = () => { // 模拟 Vue patchStyle:每个 td 都重写一遍内联样式(值有微小变化,避免被浏览器当作无变化跳过) for (const td of tds) { td.style.setProperty('--pc-probe', String(i % 7)) } i += 1 window.__pcRaf = requestAnimationFrame(loop) } window.__pcRaf = requestAnimationFrame(loop) window.__pcTdCount = tds.length } }, { writeStyle }, ) await page.waitForTimeout(500) const runs = [] for (let r = 0; r < 3; r += 1) { runs.push( await page.evaluate(async () => { const cont = document.querySelector('#__pc .stk-table') cont.scrollTop = 0 await new Promise((r2) => requestAnimationFrame(r2)) 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 < 60; i += 1) { await new Promise((r2) => requestAnimationFrame(r2)) const max = cont.scrollHeight - cont.clientHeight cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60) } 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) } }), ) } const fps = r1(median(runs.map((x) => x.fps))) const avg = r1(median(runs.map((x) => x.avg))) console.log(`${label} | ${String(fps).padStart(5)}fps (${avg}ms) [${runs.map((x) => x.fps).join('/')}]`) return { fps, avg } } const clonePlain = await cloneTest('克隆 A:原样(无任何 JS 写入) ') const cloneWrite = await cloneTest('克隆 B:+ 逐帧给全部 td 写内联样式 ', { writeStyle: true }) const tdCount = await page.evaluate(() => window.__pcTdCount ?? 0) console.log(` (克隆 B 每帧写入 ${tdCount} 个 td 的 style)`) await page.evaluate(() => { if (window.__pcRaf) cancelAnimationFrame(window.__pcRaf) document.querySelector('#__pc')?.remove() }) await page.waitForTimeout(300) const live = await stepSweep(60, 2) console.log(`live 对照(步长 60px) | ${String(live.fps).padStart(5)}fps`) /* ============================================================ */ console.log('\n################ 汇总 ################') console.log(`克隆 A 无写入 : ${clonePlain.fps} fps`) console.log(`克隆 B 逐帧写样式 : ${cloneWrite.fps} fps ← 与 A 的差 = 「DOM/样式写入」这一层的成本`) console.log(`live Vue 重渲染 : ${live.fps} fps ← 与 B 的差 = 「Vue vnode diff + 底座逻辑」这一层的成本`) console.log('\nJSON:', JSON.stringify({ sweep, clonePlain, cloneWrite, live, tdCount })) await browser.close()