u
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user