Files
workspace/code/g3soft-libs/packages/ui/probe-table-separate.mjs
T
2026-10-16 17:30:17 +08:00

260 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()