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

255 lines
10 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 性能探针 v4 —— 归因实验(只读诊断,不改业务代码)。
*
* v3 已确认:静止 61 FPS,纵向滚动 ~13 FPS。本版要回答「74ms/帧花在哪」,
* 用**受控对照**而不是继续猜:
*
* A 现状 基线
* B 去掉 validate/precision → 常驻 customCell 的列少 4 个
* C 去掉全部 editor → 一个 customCell 都不挂(性能上限参考)
* D 数据量 10 / 100 / 1000 → 判断开销是 O(可见格) 还是 O(总行数)
*
* 说明:B/C 是在**探针浏览器里**原地改内存中的列定义,用于归因;不改仓库代码。
*/
const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
async function makePage(extraQuery = '') {
const browser = await chromium.launch()
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(`${BASE}/table-test${extraQuery}`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(2500)
return { browser, context, page }
}
/** 滚动测量:真正驱动 .stk-table 的 scrollTop */
async function scrollFps(page, { axis = 'y', steps = 140, px = 60 } = {}) {
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
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)
const sync = []
for (let i = 0; i < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
const t0 = performance.now()
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)
}
sync.push(performance.now() - t0)
}
stop = true
const sorted = [...frames].sort((a, b) => a - b)
const avg = frames.reduce((a, b) => a + b, 0) / frames.length
const q = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))]
return {
fps: +(1000 / avg).toFixed(1),
avgMs: +avg.toFixed(2),
p95Ms: +q(sorted, 0.95).toFixed(2),
maxMs: +sorted[sorted.length - 1].toFixed(2),
syncMs: +(sync.reduce((a, b) => a + b, 0) / sync.length).toFixed(2),
}
},
{ axis, steps, px },
)
}
/** 取 G3Table 实例(.g3-table 的宿主组件) */
const GET_INST = `(() => {
const el = document.querySelector('.g3-table')
let inst = el && (el.__vueParentComponent || el.__vue_vnode?.component)
if (!inst) return null
// 有时拿到的是父布局,向上找带 columns 的那个
let cur = inst, hop = 0
while (cur && hop < 8) {
const ss = cur.setupState
if (ss && (ss.columns || ss.rows)) return cur
cur = cur.parent; hop++
}
return inst
})()`
async function domInfo(page) {
return page.evaluate(() => ({
bodyRows: document.querySelectorAll('.stk-tbody-main tr').length,
tds: document.querySelectorAll('.stk-tbody-main td').length,
cellWrappers: document.querySelectorAll('.stk-tbody-main td > .table-cell-wrapper').length,
displaySpans: document.querySelectorAll('.g3-table__cell-display').length,
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
}))
}
/* ============================================================
* 实验 A:现状基线
* ============================================================ */
console.log('\n########## A. 现状基线(1000 行)##########')
{
const { browser, page } = await makePage()
console.log('DOM:', JSON.stringify(await domInfo(page)))
console.log('纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' })))
console.log('横向滚动:', JSON.stringify(await scrollFps(page, { axis: 'x', steps: 100, px: 80 })))
await browser.close()
}
/* ============================================================
* 实验 B/C:削减 customCell(同页内原地改列定义)
* ============================================================ */
console.log('\n########## B/C. 削减常驻 customCell 的影响 ##########')
{
const { browser, page } = await makePage()
console.log('基线:', JSON.stringify(await scrollFps(page, { axis: 'y' })))
// B:去掉所有 validate / validateWarning / precision → 少 4 列常驻 customCell
const bRes = await page.evaluate((getInstSrc) => {
const inst = eval(getInstSrc)
if (!inst) return { error: 'no instance' }
const cols = inst.setupState.columns
const arr = cols?.value ?? cols
let n = 0
for (const c of arr) {
if (!c.editor) continue
if (c.editor.validate) { c.editor.validate = undefined; n++ }
if (c.editor.validateWarning) { c.editor.validateWarning = undefined; n++ }
if (c.editor.precision !== undefined) { c.editor.precision = undefined; n++ }
}
return { stripped: n, total: arr.length }
}, GET_INST)
console.log('B 去掉校验/精度:', JSON.stringify(bRes))
await page.waitForTimeout(1500)
console.log('B DOM:', JSON.stringify(await domInfo(page)))
console.log('B 纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' })))
// C:去掉全部 editor → 零 customCell(性能上限)
const cRes = await page.evaluate((getInstSrc) => {
const inst = eval(getInstSrc)
if (!inst) return { error: 'no instance' }
const cols = inst.setupState.columns
const arr = cols?.value ?? cols
let n = 0
for (const c of arr) {
if (c.editor) { delete c.editor; n++ }
}
return { removed: n, total: arr.length }
}, GET_INST)
console.log('C 移除全部 editor:', JSON.stringify(cRes))
await page.waitForTimeout(2000)
console.log('C DOM:', JSON.stringify(await domInfo(page)))
console.log('C 纵向滚动:', JSON.stringify(await scrollFps(page, { axis: 'y' })))
console.log('C 横向滚动:', JSON.stringify(await scrollFps(page, { axis: 'x', steps: 100, px: 80 })))
await browser.close()
}
/* ============================================================
* 实验 D:数据量(用页面自带的下拉)
* ============================================================ */
console.log('\n########## D. 数据量 → 帧率(判断 O(n) 还是 O(可见))##########')
{
const { browser, page } = await makePage()
for (const n of [10, 100, 1000, 5000]) {
await page.selectOption('.booking__select', String(n))
await page.waitForTimeout(2500)
const info = await domInfo(page)
const y = await scrollFps(page, { axis: 'y', steps: 120, px: 60 })
const x = await scrollFps(page, { axis: 'x', steps: 80, px: 80 })
console.log(
`${String(n).padStart(5)} 行 | 行DOM=${String(info.bodyRows).padStart(3)} td=${String(info.tds).padStart(4)} span=${String(info.displaySpans).padStart(4)} | 纵 ${String(y.fps).padStart(5)}fps (${y.avgMs}ms) | 横 ${String(x.fps).padStart(5)}fps (${x.avgMs}ms)`,
)
}
await browser.close()
}
/* ============================================================
* 实验 E:固定列 sticky 补丁 A/B(组件支持的 URL 开关)
* ============================================================ */
console.log('\n########## E. 固定列 sticky 补丁 A/B ##########')
{
for (const q of ['', '?g3-fixed-patch=off']) {
const { browser, page } = await makePage(q)
const y = await scrollFps(page, { axis: 'y', steps: 120, px: 60 })
const x = await scrollFps(page, { axis: 'x', steps: 80, px: 80 })
console.log(`patch${q ? ' OFF' : ' ON '} | 纵 ${y.fps}fps (${y.avgMs}ms) | 横 ${x.fps}fps (${x.avgMs}ms)`)
await browser.close()
}
}
/* ============================================================
* 实验 F:滚动期间的长任务 & 每帧样式重算
* ============================================================ */
console.log('\n########## F. 滚动期间长任务 ##########')
{
const { browser, page } = await makePage()
const res = await page.evaluate(async () => {
const tasks = []
const obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) tasks.push(+e.duration.toFixed(1))
})
try {
obs.observe({ entryTypes: ['longtask'] })
} catch {
return { unsupported: true }
}
const cont = document.querySelector('.stk-table')
for (let i = 0; i < 90; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
cont.scrollTop = (cont.scrollTop + 60) % (cont.scrollHeight - cont.clientHeight)
}
await new Promise((r) => setTimeout(r, 300))
obs.disconnect()
return {
count: tasks.length,
avg: +(tasks.reduce((a, b) => a + b, 0) / (tasks.length || 1)).toFixed(1),
max: tasks.length ? Math.max(...tasks) : 0,
sample: tasks.slice(0, 12),
}
})
console.log('长任务(>50ms):', JSON.stringify(res))
await browser.close()
}