This commit is contained in:
oneao committed 2026-10-16 17:30:17 +08:00
1 parent e7c84a82a2
commit 025d3232c4
65 files changed
+9838 -9111

No files matched your search

@@ -0,0 +1,231 @@
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()