232 lines
9.1 KiB
JavaScript
232 lines
9.1 KiB
JavaScript
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()
|