379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
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()
|