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