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,430 @@
import { chromium } from '@playwright/test'
/**
* /table-test 探针 v10 —— 决定性归因(只读诊断,不改仓库代码)。
*
* 三个关键实验:
*
* ① 冻结克隆对照(最关键)
* 把当前 `.stk-table` 的 outerHTML 克隆一份、塞进同样尺寸的容器,然后滚**克隆**。
* 克隆不受 Vue 管理 —— 滚动它不触发任何 JS 重渲染。
* · 克隆也慢 → 瓶颈在浏览器侧(样式/布局/绘制/合成)
* · 克隆很快 → 瓶颈在 Vue/库的 JS 重渲染
*
* ② LoAF 完整字段(含 forcedStyleAndLayoutDuration / invoker / 暂停时长)
*
* ③ 数据量对照再确认:100 行(虚拟滚动关闭,无逐帧 DOM 改动)vs 1000 行(虚拟开启)
*
* ④ errorCount:一次真实「编辑提交」会额外付多少主线程时间
*/
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].sort((x, y) => x - y)
return s.length ? s[Math.floor(s.length / 2)] : null
}
const r1 = (n) => (n == null ? null : +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 response = await route.fetch()
if (viewName !== 's_power' && viewName !== 's_user_power') return route.fulfill({ response })
let json
try {
json = await response.json()
} catch {
return route.fulfill({ response })
}
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,
body: JSON.stringify(json),
headers: { ...response.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)
/** 通用滚动驱动:对指定选择器('live' | 'clone')滚动并测帧率 */
async function drive(which, { axis = 'y', steps = 60, px = 60 } = {}) {
return page.evaluate(
async ({ which, axis, steps, px }) => {
const sel = which === 'live' ? '#__probe_live .stk-table' : '#__probe_clone .stk-table'
const cont = document.querySelector(sel)
if (!cont) return { error: 'missing ' + sel }
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
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 < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
if (axis === 'y') {
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + px)
} else {
const max = cont.scrollWidth - cont.clientWidth
cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + px)
}
}
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),
scrollH: cont.scrollHeight,
clientH: cont.clientHeight,
scrollW: cont.scrollWidth,
clientW: cont.clientWidth,
}
},
{ which, axis, steps, px },
)
}
/* ============================================================
* ① 冻结克隆对照
* ============================================================ */
console.log('################ ① 冻结克隆对照 ################')
const setup = await page.evaluate(() => {
const live = document.querySelector('.stk-table')
if (!live) return { error: 'no live table' }
const r = live.getBoundingClientRect()
// 给 live 包一个稳定 id 便于选择
live.id = '__probe_live_inner'
if (!live.parentElement.id) live.parentElement.id = '__probe_live'
// 克隆整棵子树(含 530 个 td / 全部 class),放进同样尺寸的容器
const clone = live.cloneNode(true)
clone.id = '__probe_clone_inner'
clone.removeAttribute('data-v-app')
const holder = document.createElement('div')
holder.id = '__probe_clone'
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)
return {
width: r.width,
height: r.height,
liveSel: '#__probe_live .stk-table',
cloneSel: '#__probe_clone .stk-table',
liveTds: live.querySelectorAll('tbody td').length,
cloneTds: clone.querySelectorAll('tbody td').length,
}
})
console.log('克隆搭建:', JSON.stringify(setup))
// 隐藏克隆时先测 live(避免克隆覆盖带来的合成差异)
await page.evaluate(() => {
const h = document.querySelector('#__probe_clone')
if (h) h.style.visibility = 'hidden'
})
await page.waitForTimeout(300)
const liveY = []
for (let i = 0; i < 3; i += 1) liveY.push(await drive('live', { axis: 'y', steps: 55, px: 60 }))
await page.evaluate(() => {
const h = document.querySelector('#__probe_clone')
if (h) h.remove()
})
console.log('live(Vue 管理,虚拟滚动逐帧改 DOM)纵向 ×3:', JSON.stringify(liveY.map((r) => r.fps)))
/* --- 重建克隆:A) 原样克隆(含 sticky / 渐变 / class) --- */
async function cloneTest(label, mutateCss) {
await page.evaluate((css) => {
document.querySelector('#__probe_clone')?.remove()
const live = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table')
const r = live.getBoundingClientRect()
const clone = live.cloneNode(true)
clone.removeAttribute('id')
const holder = document.createElement('div')
holder.id = '__probe_clone'
holder.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;height:${r.height}px;z-index:99999;background:#fff;`
// 绝对定位的 mask:把 live 盖住(量的是可见区域的真实光栅化成本)
holder.appendChild(clone)
document.body.appendChild(holder)
if (css) {
const s = document.createElement('style')
s.dataset.clone = '1'
s.textContent = css
document.head.appendChild(s)
}
}, mutateCss || '')
await page.waitForTimeout(500)
const runs = []
for (let i = 0; i < 3; i += 1) runs.push(await drive('clone', { axis: 'y', steps: 55, px: 60 }))
const runsX = []
for (let i = 0; i < 2; i += 1) runsX.push(await drive('clone', { axis: 'x', steps: 45, px: 80 }))
console.log(
`${label}\n 纵 ${runs.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runs.map((r) => r.fps)))}) | 横 ${runsX.map((r) => r.fps).join('/')} fps (中位 ${r1(median(runsX.map((r) => r.fps)))})`,
)
await page.evaluate(() => {
document.querySelectorAll('style[data-clone]').forEach((s) => s.remove())
document.querySelector('#__probe_clone')?.remove()
})
await page.waitForTimeout(200)
return { y: r1(median(runs.map((r) => r.fps))), x: r1(median(runsX.map((r) => r.fps))) }
}
console.log('\n--- 克隆(无 Vue 参与,纯浏览器滚动同一份 DOM)---')
const cloneResult = await cloneTest('A) 原样克隆')
await cloneTest(
'B) 克隆 + 去掉固定列 sticky',
`#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; }`,
)
await cloneTest(
'C) 克隆 + 去掉单元格背景渐变(格线)',
`#__probe_clone td, #__probe_clone th { background-image: none !important; }`,
)
await cloneTest(
'D) 克隆 + 去掉 sticky 与全部 box-shadow',
`#__probe_clone td.fixed-cell, #__probe_clone th.fixed-cell { position: static !important; }
#__probe_clone td, #__probe_clone th, #__probe_clone tr { box-shadow: none !important; }`,
)
/* ============================================================
* ② LoAF 完整字段
* ============================================================ */
console.log('\n################ ② LoAF 完整字段(纵向滚动)################')
const loaf = await page.evaluate(async () => {
const cont = document.querySelector('#__probe_live .stk-table') || document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const entries = []
const obs = new PerformanceObserver((l) => {
for (const e of l.getEntries()) entries.push(e)
})
obs.observe({ type: 'long-animation-frame', buffered: false })
for (let i = 0; i < 45; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60)
}
await new Promise((r) => setTimeout(r, 250))
obs.disconnect()
const scripts = []
const frames = []
for (const e of entries) {
frames.push({
duration: +e.duration.toFixed(1),
blocking: +(e.blockingDuration || 0).toFixed(1),
renderStart: +(e.renderStart || 0).toFixed(1),
styleLayoutStart: +(e.styleAndLayoutStart || 0).toFixed(1),
scripts: (e.scripts || []).map((s) => ({
name: s.name,
entryType: s.entryType,
invoker: s.invoker,
invokerType: s.invokerType,
duration: +s.duration.toFixed(1),
forcedStyleAndLayout:
s.forcedStyleAndLayoutDuration != null ? +s.forcedStyleAndLayoutDuration.toFixed(1) : undefined,
pause: s.pauseDuration != null ? +s.pauseDuration.toFixed(1) : undefined,
url: (s.sourceURL || '').split('/').slice(-1)[0].split('?')[0],
fn: s.sourceFunctionName,
pos: s.sourceCharPosition,
})),
})
for (const s of e.scripts || []) scripts.push(s)
}
// 按脚本来源汇总
const byKey = new Map()
for (const s of scripts) {
const key = `${s.name || s.invoker} | ${(s.sourceURL || '').split('/').slice(-1)[0].split('?')[0]} | fn=${s.sourceFunctionName || '-'} | pos=${s.sourceCharPosition} | invokerType=${s.invokerType}`
const cur = byKey.get(key) || { total: 0, n: 0, forced: 0, pause: 0 }
cur.total += s.duration
cur.n += 1
cur.forced += s.forcedStyleAndLayoutDuration || 0
cur.pause += s.pauseDuration || 0
byKey.set(key, cur)
}
return {
loafCount: frames.length,
totalLoafMs: +frames.reduce((a, f) => a + f.duration, 0).toFixed(0),
totalBlockingMs: +frames.reduce((a, f) => a + f.blocking, 0).toFixed(0),
sampleFrames: frames.slice(0, 3),
scriptSources: [...byKey.entries()]
.map(([k, v]) => ({
key: k,
totalMs: +v.total.toFixed(1),
calls: v.n,
forcedStyleLayoutMs: +v.forced.toFixed(1),
pauseMs: +v.pause.toFixed(1),
}))
.sort((a, b) => b.totalMs - a.totalMs)
.slice(0, 8),
}
})
console.log(JSON.stringify(loaf, null, 2))
/* ============================================================
* ③ 数据量对照(确认「逐帧 DOM 改动」是否为主因)
* ============================================================ */
console.log('\n################ ③ 数据量对照:虚拟滚动开/关 ################')
for (const n of [10, 100, 1000]) {
await page.selectOption('.booking__select', String(n))
await page.waitForTimeout(2200)
const info = await page.evaluate(() => {
const t = document.querySelector('.stk-table')
return {
cls: t.className.includes('vt-on') ? 'vt-on(虚拟开启)' : 'vt-off(全量渲染)',
trs: document.querySelectorAll('.stk-tbody-main tr').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
scrollH: t.scrollHeight,
clientH: t.clientHeight,
}
})
// 逐帧 DOM 改动计数
const churn = await page.evaluate(async () => {
const tbody = document.querySelector('.stk-tbody-main')
const cont = document.querySelector('.stk-table')
let child = 0
let attrs = 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') attrs += 1
}
})
obs.observe(tbody, { childList: true, subtree: true, attributes: true })
const F = 40
for (let i = 0; i < F; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + 60)
}
await new Promise((r) => setTimeout(r, 60))
obs.disconnect()
return { childPerFrame: +(child / F).toFixed(1), attrPerFrame: +(attrs / F).toFixed(1) }
})
const y = await drive('live', { axis: 'y', steps: 50, px: 60 })
const x = await drive('live', { axis: 'x', steps: 40, px: 80 })
console.log(
`${String(n).padStart(5)} 行 ${info.cls.padEnd(16)} tr=${String(info.trs).padStart(3)} td=${String(info.tds).padStart(4)} | 逐帧 节点${String(churn.childPerFrame).padStart(5)} 属性${String(churn.attrPerFrame).padStart(5)} | 纵 ${String(y.fps).padStart(5)}fps | 横 ${String(x.fps).padStart(5)}fps`,
)
}
/* ============================================================
* ④ errorCount:一次「编辑提交」的额外成本
* ============================================================ */
console.log('\n################ ④ 编辑提交 → errorCount 全表校验成本 ################')
await page.selectOption('.booking__select', '1000')
await page.waitForTimeout(2200)
const commitCost = await page.evaluate(() => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
if (!i) return { error: 'no page instance' }
const ss = i.setupState
const rows = ss.rows?.value ?? ss.rows
void ss.errorCount?.value // 预热
// 模拟「编辑提交」:写一个响应式行字段(editors 的 commitInputDraft 就是这么做的)
const out = []
for (let k = 0; k < 5; k += 1) {
const r = rows[k]
const t0 = performance.now()
r.remark = `${r.remark ?? ''}x`
// 等 Vue 把 errorCount 重算 + 页面重渲染跑完
const t1 = performance.now()
out.push({ writeMs: +(t1 - t0).toFixed(2) })
r.remark = String(r.remark).slice(0, -1)
}
return { rows: rows.length, out }
})
console.log('提交写入本身:', JSON.stringify(commitCost))
// 直接量 errorCount 重算
const recompute = await page.evaluate(() => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
const ss = i.setupState
const rows = ss.rows?.value ?? ss.rows
const out = []
for (let k = 0; k < 3; k += 1) {
void ss.errorCount?.value
if (rows?.length) rows[0].freight = Number(rows[0].freight) + 0.001
const t0 = performance.now()
void ss.errorCount?.value
out.push(+(performance.now() - t0).toFixed(1))
if (rows?.length) rows[0].freight = Number(rows[0].freight) - 0.001
}
return { rows: rows.length, cols: (ss.columns?.value ?? ss.columns)?.length, ms: out }
})
console.log('errorCount 重算(1000 行 × 22 列):', JSON.stringify(recompute))
// 真实点击 → 输入 → 失焦提交 的端到端
const e2e = await page.evaluate(async () => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
await new Promise((r) => requestAnimationFrame(r))
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][2]
// 找「备注」列(最右固定列)
const tds = [...tr.querySelectorAll('td')]
const td = tds[tds.length - 1]
const r = td.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const t0 = performance.now()
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent
td.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 }))
}
let toEditor = -1
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
toEditor = performance.now() - t0
break
}
await new Promise((r2) => requestAnimationFrame(r2))
}
const input = td.querySelector('input')
if (!input) return { toEditor: +toEditor.toFixed(1), error: 'no input' }
input.focus()
input.value = `${input.value}z`
input.dispatchEvent(new Event('input', { bubbles: true }))
const t1 = performance.now()
input.blur() // 触发 commit + errorCount 重算
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => setTimeout(r2, 0))
return {
toEditorMs: +toEditor.toFixed(1),
commitMs: +(performance.now() - t1).toFixed(1),
}
})
console.log('端到端(点击→编辑器 / 失焦提交):', JSON.stringify(e2e))
await browser.close()