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,378 @@
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()