This commit is contained in:
oneao committed 2026-10-16 17:30:17 +08:00
1 parent e7c84a82a2
commit 025d3232c4
65 files changed
+9838 -9111

No files matched your search

@@ -0,0 +1,259 @@
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()