260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
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()
|