255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
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()
|
||
}
|