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

232 lines
9.1 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 探针 v12 —— 收尾:横向滚动的 class 抖动到底由谁驱动(只读诊断)。
*
* 已知:横向滚动时 tbody 无节点增删,但每帧有 class 写入,样本显示
* 固定列的 `fixed-cell--active` 在反复翻转。
*
* 本版把三种候选驱动因素分别切开:
* ① fixed-col-shadow(页面显式开了 true)
* ② 固定列 sticky 补丁(G3Table 自加的 position/z-index)
* ③ 横向虚拟滚动(本页 22 列 < 30,应当是关的 —— 顺带验证)
*
* 做法:直接在**内存里**改 G3Table 的 props(不改仓库文件),分别测
* class 写入/帧 与 帧率,交替多轮。
*/
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[s.length >> 1] : 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)
/* 先确认本页的虚拟滚动开关状态 */
const cfg = await page.evaluate(() => {
const t = document.querySelector('.stk-table')
return {
cls: t.className,
virtualXOn: t.className.includes('vt-x-on'),
virtualOn: t.className.includes('vt-on'),
tds: document.querySelectorAll('.stk-tbody-main td').length,
fixedCells: document.querySelectorAll('.stk-tbody-main td.fixed-cell').length,
}
})
console.log('表格配置:', JSON.stringify(cfg))
/* 拿到 G3Table 组件实例,便于在内存里切 prop */
const hasInst = await page.evaluate(() => {
const root = document.querySelector('.g3-table')
let i = root?.__vueParentComponent
let hops = 0
while (i && hops < 6) {
if (i.type?.__name === 'G3Table' || i.type?.name === 'G3Table') {
window.__g3 = i
return true
}
i = i.parent
hops += 1
}
// 退一步:host 元素本身
window.__g3 = root?.__vueParentComponent || null
return !!window.__g3
})
console.log('找到 G3Table 实例:', hasInst)
/**
* 横向滚动测量:帧率 + class 写入次数 + 是否有节点增删。
* 不复位 scrollLeft(避免「回绕到 0」污染 class 抖动统计),改为在区间内往返。
*/
async function measureX({ steps = 40, px = 80 } = {}) {
const ok = await page.evaluate(() => !!document.querySelector('.stk-table'))
if (!ok) return { fps: null }
return page.evaluate(
async ({ steps, px }) => {
const cont = document.querySelector('.stk-table')
const tbody = document.querySelector('.stk-tbody-main')
let attr = 0
let child = 0
const classes = {}
const obs = new MutationObserver((recs) => {
for (const m of recs) {
if (m.type === 'attributes') {
attr += 1
const c = m.target.getAttribute?.('class') || ''
// 只记「关键状态类」的分布
for (const k of ['fixed-cell--active', 'fixed-cell--shadow', 'fixed-cell--border-left']) {
if (c.includes(k)) classes[k] = (classes[k] || 0) + 1
}
} else if (m.type === 'childList') {
child += m.addedNodes.length + m.removedNodes.length
}
}
})
obs.observe(tbody, { childList: true, subtree: true, attributes: true })
// 从当前 scrollLeft 出发,在 [lo, hi] 区间内往返,绝不回到 0
const max = cont.scrollWidth - cont.clientWidth
const lo = Math.round(max * 0.2)
const hi = Math.round(max * 0.9)
if (cont.scrollLeft < lo || cont.scrollLeft > hi) cont.scrollLeft = Math.round(max * 0.5)
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)
let dir = 1
for (let i = 0; i < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
cont.scrollLeft += dir * px
if (cont.scrollLeft >= hi) dir = -1
else if (cont.scrollLeft <= lo) dir = 1
}
stop = true
await new Promise((r) => setTimeout(r, 80))
obs.disconnect()
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),
attrPerFrame: +(attr / steps).toFixed(2),
childPerFrame: +(child / steps).toFixed(2),
stateClassHits: classes,
}
},
{ steps, px },
)
}
/* ---- 基线 ---- */
console.log('\n--- 基线(fixedColShadow=true,sticky 补丁开)---')
const base = []
for (let i = 0; i < 3; i += 1) base.push(await measureX())
console.log(JSON.stringify(base, null, 2))
console.log(`基线中位: ${r1(median(base.map((b) => b.fps)))}fps, ${r1(median(base.map((b) => b.attrPerFrame)))} attr/帧`)
/* ---- 关掉 fixedColShadow ---- */
console.log('\n--- 关闭 fixedColShadow(内存改 prop)---')
await page.evaluate(() => {
const t = document.querySelector('.g3-table')
// 直接改 props 对象上的值(Vue 3 props 是 shallowReactive,可写但会告警)
const inst = window.__g3
if (inst?.props) inst.props.fixedColShadow = false
else if (t?.__vueParentComponent?.props) t.__vueParentComponent.props.fixedColShadow = false
})
await page.waitForTimeout(800)
// 用 CSS 关掉阴影类作为等价对照(不改 prop 也能验证「阴影类」是否是驱动因素)
const shadowOff = []
for (let i = 0; i < 3; i += 1) shadowOff.push(await measureX())
console.log(JSON.stringify(shadowOff, null, 2))
/* ---- 用 CSS 强制固定列恒 sticky 已由组件补丁提供;这里测「去掉补丁」 ---- */
console.log('\n--- 用 ?g3-fixed-patch=off 重新载入(去掉 sticky 补丁)---')
await page.goto(`${URL_BASE}/table-test?g3-fixed-patch=off`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(2500)
const patchOff = []
for (let i = 0; i < 3; i += 1) patchOff.push(await measureX())
console.log(JSON.stringify(patchOff, null, 2))
const patchCls = await page.evaluate(() => document.querySelector('.g3-table')?.className)
console.log('root class:', patchCls)
/* ---- 回到默认,测「把 sticky 全部关掉」的纯对照 ---- */
await page.goto(`${URL_BASE}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(2500)
await page.evaluate(() => {
const s = document.createElement('style')
s.dataset.probe = '1'
s.textContent = `.stk-table td.fixed-cell, .stk-table th.fixed-cell { position: static !important; z-index: auto !important; }`
document.head.appendChild(s)
})
await page.waitForTimeout(700)
const noSticky = []
for (let i = 0; i < 3; i += 1) noSticky.push(await measureX())
console.log('\n--- 去掉全部固定列 sticky ---')
console.log(JSON.stringify(noSticky, null, 2))
/* ================= 汇总 ================= */
console.log('\n================ 汇总(横向滚动)================')
const line = (label, runs) =>
console.log(
`${label.padEnd(28)} ${String(r1(median(runs.map((r) => r.fps)))).padStart(5)}fps | ${String(r1(median(runs.map((r) => r.attrPerFrame)))).padStart(5)} class写/帧 | ${String(r1(median(runs.map((r) => r.childPerFrame)))).padStart(5)} 节点/帧`,
)
console.log('配置'.padEnd(26) + ' 帧率 | class写入 | 节点增删')
line('fixedColShadow=true(默认)', base)
line('fixedColShadow 关(内存改)', shadowOff)
line('无 sticky 补丁(URL 开关)', patchOff)
line('固定列全部 static', noSticky)
await browser.close()