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

379 lines
15 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'
/**
* /table-test 探针 v11 —— 最终确认(只读诊断,不改仓库代码)。
*
* 已有结论(v1~v10):
* · 静止 61fps;纵向 ~10-15fps;横向 ~18-28fps;编辑态纵向 ~8-11fps
* · **同一份 DOM 的冻结克隆滚到 60fps** → 瓶颈在 JS 重渲染,不在 CSS/绘制
* · LoAF:滚动帧 ~90% 时间挂在一个 user-callback(库的 rAF)
* · 首次点击 = 全表列重建(1008 次 DOM 变更)→ 300-500ms
*
* 本版要拿下的最后几个数字(都用交替多轮取中位,避免单轮方差):
* ① 全部 editor 移除 vs 保留 → customCell 层占多少(纵向/横向)
* ② 首次点击 / 换行点击 / 同格点击 的耗时与 DOM 变更量(含 thead 是否重建)
* ③ 「保存明细」按钮(findFirstError 全表校验)在 1000 / 5000 行下的耗时
* ④ errorCount 在 1000 / 5000 行的重算耗时 + 一次提交的端到端延迟
*/
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 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)
async function scrollFps(page, { axis = 'y', steps = 50, px = 60 } = {}) {
const ok = await page.evaluate(() => !!document.querySelector('.stk-table'))
if (!ok) return { fps: null }
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
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) }
},
{ axis, steps, px },
)
}
/* ============================================================
* ① editor 保留 vs 移除(交替 3 轮)
* ============================================================ */
console.log('################ ① customCell(editor)层的开销 ################')
// 把页面 columns 里的 editor 摘下来存好,便于来回切换
const setup = await page.evaluate(() => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.columns) i = i.parent
if (!i) return { error: 'no page instance' }
const cols = i.setupState.columns
const arr = cols?.value ?? cols
// 存到 window,供后续 A/B
window.__probeSaved = arr.map((c) => c.editor)
window.__probeCols = arr
return { cols: arr.length, withEditor: arr.filter((c) => c.editor).length }
})
console.log('页面列:', JSON.stringify(setup))
async function setEditors(on) {
await page.evaluate((on) => {
const arr = window.__probeCols
const saved = window.__probeSaved
if (!arr) return
arr.forEach((c, idx) => {
if (on) {
if (saved[idx]) c.editor = saved[idx]
} else if (c.editor) {
delete c.editor
}
})
}, on)
await page.evaluate(
() =>
new Promise((r) => {
requestAnimationFrame(() => requestAnimationFrame(r))
}),
)
await page.waitForTimeout(700)
}
const onY = []
const onX = []
const offY = []
const offX = []
for (let r = 0; r < 3; r += 1) {
await setEditors(true)
onY.push((await scrollFps(page, { axis: 'y', steps: 50, px: 60 })).fps)
onX.push((await scrollFps(page, { axis: 'x', steps: 45, px: 80 })).fps)
await setEditors(false)
offY.push((await scrollFps(page, { axis: 'y', steps: 50, px: 60 })).fps)
offX.push((await scrollFps(page, { axis: 'x', steps: 45, px: 80 })).fps)
}
await setEditors(true)
const domOn = await page.evaluate(() => ({
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
displaySpans: document.querySelectorAll('.g3-table__cell-display').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
}))
console.log(`保留全部 editor | 纵 ${r1(median(onY))}fps (${onY.join('/')}) | 横 ${r1(median(onX))}fps (${onX.join('/')})`)
console.log(`移除全部 editor | 纵 ${r1(median(offY))}fps (${offY.join('/')}) | 横 ${r1(median(offX))}fps (${offX.join('/')})`)
console.log('保留 editor 时 DOM:', JSON.stringify(domOn))
/* ============================================================
* ② 点击三种场景 + thead 是否重建
* ============================================================ */
console.log('\n################ ② 点击进入编辑:场景与 DOM 变更 ################')
async function clickProbe(rowIdx, colIdx, settle = 350) {
await page.waitForTimeout(settle)
return page.evaluate(
async ({ rowIdx, colIdx }) => {
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][rowIdx]
if (!tr) return { error: 'no row' }
const td = [...tr.querySelectorAll('td')][colIdx]
if (!td) return { error: 'no td' }
const r = td.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) return { error: 'offscreen' }
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const tbody = document.querySelector('.stk-tbody-main')
const thead = document.querySelector('thead')
let tbChild = 0
let tbAttr = 0
let thChild = 0
const obs = new MutationObserver((recs) => {
for (const m of recs) {
const isHead = thead.contains(m.target)
if (m.type === 'childList') {
if (isHead) thChild += m.addedNodes.length + m.removedNodes.length
else tbChild += m.addedNodes.length + m.removedNodes.length
} else if (m.type === 'attributes' && !isHead) tbAttr += 1
}
})
obs.observe(document.querySelector('.stk-table'), {
childList: true,
subtree: true,
attributes: true,
})
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 }),
)
}
const dispatchMs = +(performance.now() - t0).toFixed(2)
let mounted = -1
const deadline = performance.now() + 4000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
mounted = performance.now() - t0
break
}
await new Promise((r2) => requestAnimationFrame(r2))
}
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => setTimeout(r2, 120))
obs.disconnect()
return {
dispatchMs,
toEditorMs: +mounted.toFixed(1),
tbChild,
tbAttr,
thChild,
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
}
},
{ rowIdx, colIdx },
)
}
// 重新载入以拿到「真正的首次点击」
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
const first = await clickProbe(3, 6, 500)
console.log('A. 页面加载后首次点击:', JSON.stringify(first))
const rowsSwitch = []
for (const r of [6, 8, 10, 12]) rowsSwitch.push(await clickProbe(r, 6, 350))
console.log('B. 换行点击 ×4:', JSON.stringify(rowsSwitch.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr, thChild: x.thChild }))))
const same = []
for (let i = 0; i < 3; i += 1) same.push(await clickProbe(12, 6, 250))
console.log('C. 同格连点 ×3:', JSON.stringify(same.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr }))))
const colSwitch = []
for (const c of [2, 3, 5]) colSwitch.push(await clickProbe(12, c, 350))
console.log('D. 同行换列 ×3:', JSON.stringify(colSwitch.map((x) => ({ ms: x.toEditorMs, tbChild: x.tbChild, tbAttr: x.tbAttr }))))
/* ============================================================
* ③ 保存按钮(findFirstError 全表校验)
* ============================================================ */
console.log('\n################ ③ 「保存明细」全表校验(findFirstError)################')
for (const n of [1000, 5000]) {
await page.selectOption('.booking__select', String(n))
await page.waitForTimeout(2600)
const res = await page.evaluate(async () => {
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
const cols = ss.columns?.value ?? ss.columns
// 直接跑一遍与 findFirstError/errorCount 等价的循环,量 O(rows×cols) 成本
const t0 = performance.now()
let checks = 0
for (let a = 0; a < rows.length; a += 1) {
const row = rows[a]
for (let b = 0; b < cols.length; b += 1) {
const col = cols[b]
if (!col.editor) continue
checks += 1
const precision =
typeof col.editor.precision === 'function'
? col.editor.precision(row)
: col.editor.precision
void (col.editor.validate?.(row[col.dataIndex], row, col) || precision)
}
}
const loopMs = performance.now() - t0
// errorCount 强制重算
void ss.errorCount?.value
rows[0].freight = Number(rows[0].freight) + 0.001
const t1 = performance.now()
const ec = ss.errorCount?.value
const errorCountMs = performance.now() - t1
rows[0].freight = Number(rows[0].freight) - 0.001
// 真实点「保存明细」按钮的端到端
const btn = [...document.querySelectorAll('.booking__btn')].find((b) =>
b.textContent.includes('保存明细'),
)
const t2 = performance.now()
btn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => setTimeout(r2, 0))
const saveMs = performance.now() - t2
return {
rows: rows.length,
cols: cols.length,
editorCols: cols.filter((c) => c.editor).length,
checks,
loopMs: +loopMs.toFixed(1),
errorCountMs: +errorCountMs.toFixed(1),
errorCount: ec,
saveClickMs: +saveMs.toFixed(1),
}
})
console.log(` ${JSON.stringify(res)}`)
}
/* ============================================================
* ④ 提交一次编辑的端到端 + errorCount
* ============================================================ */
console.log('\n################ ④ 一次编辑提交的端到端延迟 ################')
for (const n of [1000, 5000]) {
await page.selectOption('.booking__select', String(n))
await page.waitForTimeout(2600)
const res = 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]
if (!tr) return { error: 'no row' }
const tds = [...tr.querySelectorAll('td')]
const td = tds[tds.length - 1] // 备注列(最右固定列,text editor)
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() + 4000
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 { toEditorMs: +toEditor.toFixed(1), error: 'no input' }
input.focus()
const t1 = performance.now()
input.value = `${input.value}Z`
input.dispatchEvent(new Event('input', { bubbles: true }))
const typeMs = performance.now() - t1
const t2 = performance.now()
input.blur()
await new Promise((r2) => requestAnimationFrame(r2))
await new Promise((r2) => setTimeout(r2, 0))
await new Promise((r2) => requestAnimationFrame(r2))
return {
toEditorMs: +toEditor.toFixed(1),
typeMs: +typeMs.toFixed(1),
commitMs: +(performance.now() - t2).toFixed(1),
}
})
console.log(` ${n} 行: ${JSON.stringify(res)}`)
}
await browser.close()