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()