u
This commit is contained in:
1 parent
e7c84a82a2
commit
025d3232c4
65 files changed
+9838
-9111
No files matched your search
@@ -0,0 +1,284 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* /table-test 性能探针 v3(只读诊断,不改业务代码)。
|
||||
*
|
||||
* v2 的教训:滚动了 `.stk-table` 的**父节点**(.g3-table),那个元素并不滚动
|
||||
* (scrollHeight == clientHeight),于是「假滚动」+ 99.6% idle。
|
||||
* 真正的滚动容器是 `.stk-table` 自身(style.less 里它带 overflow: auto)。
|
||||
* 本版先在页面里**探测出真正可滚动的元素**再测量,不靠猜。
|
||||
*/
|
||||
|
||||
const BASE = process.env.PROBE_URL || 'http://127.0.0.1:5090'
|
||||
const MENU_CODE = 'menu.table_test'
|
||||
|
||||
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, 300)))
|
||||
|
||||
await page.goto(`${BASE}/table-test`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('.stk-table', { timeout: 90000 })
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
/* ---------- 0. 找出真正滚动的元素 ---------- */
|
||||
const containers = await page.evaluate(() => {
|
||||
const out = []
|
||||
const walk = (el, depth = 0) => {
|
||||
if (depth > 14) return
|
||||
const cs = getComputedStyle(el)
|
||||
const oy = cs.overflowY
|
||||
const ox = cs.overflowX
|
||||
const scrollableY = /auto|scroll/.test(oy) && el.scrollHeight > el.clientHeight + 4
|
||||
const scrollableX = /auto|scroll/.test(ox) && el.scrollWidth > el.clientWidth + 4
|
||||
if (scrollableY || scrollableX) {
|
||||
out.push({
|
||||
tag: el.tagName,
|
||||
cls: (el.className || '').toString().slice(0, 70),
|
||||
oy,
|
||||
ox,
|
||||
scrollableY,
|
||||
scrollableX,
|
||||
scrollH: el.scrollHeight,
|
||||
clientH: el.clientHeight,
|
||||
scrollW: el.scrollWidth,
|
||||
clientW: el.clientWidth,
|
||||
})
|
||||
}
|
||||
for (const c of el.children) walk(c, depth + 1)
|
||||
}
|
||||
walk(document.body)
|
||||
return out
|
||||
})
|
||||
console.log('=== 可滚动元素 ===')
|
||||
console.log(JSON.stringify(containers, null, 2))
|
||||
|
||||
const info = await page.evaluate(() => ({
|
||||
bodyRows: document.querySelectorAll('.stk-tbody-main tr').length,
|
||||
tds: document.querySelectorAll('.stk-tbody-main td').length,
|
||||
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
|
||||
displaySpans: document.querySelectorAll('.g3-table__cell-display').length,
|
||||
selects: document.querySelectorAll('.g3-select').length,
|
||||
inputs: document.querySelectorAll('.g3-input').length,
|
||||
innerCls: document.querySelector('.stk-table')?.className,
|
||||
}))
|
||||
console.log('=== DOM ===', JSON.stringify(info))
|
||||
|
||||
/* ---------- 1. 帧率测量:真正驱动滚动 ---------- */
|
||||
async function measureScroll({ axis = 'y', steps = 150, px = 60, label = '' } = {}) {
|
||||
const res = await page.evaluate(
|
||||
async ({ axis, steps, px }) => {
|
||||
// 找到真正能滚的容器
|
||||
let cont = document.querySelector('.stk-table')
|
||||
const canScroll = (el, a) =>
|
||||
a === 'y' ? el.scrollHeight > el.clientHeight + 4 : el.scrollWidth > el.clientWidth + 4
|
||||
if (!canScroll(cont, axis)) {
|
||||
const all = [...document.querySelectorAll('*')]
|
||||
cont = all.find((el) => canScroll(el, axis))
|
||||
}
|
||||
if (!cont) return { error: 'no scrollable container' }
|
||||
|
||||
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 {
|
||||
cont: cont.className?.toString().slice(0, 40),
|
||||
frames: frames.length,
|
||||
fps: +(1000 / avg).toFixed(1),
|
||||
avgMs: +avg.toFixed(2),
|
||||
p50: +q(sorted, 0.5).toFixed(2),
|
||||
p95: +q(sorted, 0.95).toFixed(2),
|
||||
p99: +q(sorted, 0.99).toFixed(2),
|
||||
max: +sorted[sorted.length - 1].toFixed(2),
|
||||
over16: frames.filter((f) => f > 16.7).length,
|
||||
over33: frames.filter((f) => f > 33).length,
|
||||
over50: frames.filter((f) => f > 50).length,
|
||||
syncAvg: +(sync.reduce((a, b) => a + b, 0) / sync.length).toFixed(2),
|
||||
syncP95: +q([...sync].sort((a, b) => a - b), 0.95).toFixed(2),
|
||||
}
|
||||
},
|
||||
{ axis, steps, px },
|
||||
)
|
||||
console.log(`滚动[${label}] ${JSON.stringify(res)}`)
|
||||
return res
|
||||
}
|
||||
|
||||
/* ---------- 2. 点击延迟 ---------- */
|
||||
async function measureClick({ label = '' } = {}) {
|
||||
const res = await page.evaluate(async () => {
|
||||
const cont = document.querySelector('.stk-table')
|
||||
cont.scrollTop = 0
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
const trs = [...document.querySelectorAll('.stk-tbody-main tr')].slice(1, 13)
|
||||
const out = []
|
||||
for (const tr of trs) {
|
||||
const tds = [...tr.querySelectorAll('td')]
|
||||
const td = tds[6]
|
||||
if (!td) continue
|
||||
const r = td.getBoundingClientRect()
|
||||
if (r.top < 40 || r.bottom > window.innerHeight) continue
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
|
||||
const t0 = performance.now()
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent
|
||||
td.dispatchEvent(
|
||||
new Ctor(type, { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 }),
|
||||
)
|
||||
}
|
||||
// 等到编辑器真的出现在 DOM 里(轮询 + rAF,测「用户可感知的就绪时刻」)
|
||||
let toEditor = -1
|
||||
const deadline = performance.now() + 2000
|
||||
while (performance.now() < deadline) {
|
||||
if (td.querySelector('.g3-table__cell-editor-root')) {
|
||||
toEditor = performance.now() - t0
|
||||
break
|
||||
}
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 120))
|
||||
out.push(+toEditor.toFixed(2))
|
||||
}
|
||||
return out
|
||||
})
|
||||
const vals = res.filter((v) => v >= 0).sort((a, b) => a - b)
|
||||
const summary = {
|
||||
n: vals.length,
|
||||
min: vals.length ? +vals[0].toFixed(1) : null,
|
||||
median: vals.length ? +vals[Math.floor(vals.length / 2)].toFixed(1) : null,
|
||||
p95: vals.length ? +vals[Math.floor(vals.length * 0.95)].toFixed(1) : null,
|
||||
max: vals.length ? +vals[vals.length - 1].toFixed(1) : null,
|
||||
all: vals.map((v) => +v.toFixed(1)),
|
||||
}
|
||||
console.log(`点击→编辑器就绪[${label}] ${JSON.stringify(summary)}`)
|
||||
return summary
|
||||
}
|
||||
|
||||
/* ---------- 3. CPU profile ---------- */
|
||||
async function profile(label, action) {
|
||||
const client = await context.newCDPSession(page)
|
||||
await client.send('Profiler.enable')
|
||||
await client.send('Profiler.setSamplingInterval', { interval: 150 })
|
||||
await client.send('Profiler.start')
|
||||
await action()
|
||||
const { profile: p } = await client.send('Profiler.stop')
|
||||
await client.detach()
|
||||
|
||||
const byId = new Map(p.nodes.map((n) => [n.id, n]))
|
||||
const self = new Map()
|
||||
const total = p.samples.length || 1
|
||||
for (const id of p.samples) {
|
||||
const n = byId.get(id)
|
||||
if (!n) continue
|
||||
const cf = n.callFrame
|
||||
const file = (cf.url || '').split('/').slice(-1)[0].split('?')[0]
|
||||
const key = `${cf.functionName || '(anon)'} @ ${file}:${cf.lineNumber + 1}`
|
||||
self.set(key, (self.get(key) || 0) + 1)
|
||||
}
|
||||
console.log(`\n===== CPU 热点:${label}(总采样 ${total})=====`)
|
||||
for (const [k, c] of [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 28)) {
|
||||
console.log(`${((c / total) * 100).toFixed(1).padStart(5)}% ${k}`)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
/* ---------- 执行 ---------- */
|
||||
console.log('\n---------- 静止基线(无滚动)----------')
|
||||
await measureScroll({ axis: 'y', steps: 60, px: 0, label: '基线-不动' })
|
||||
|
||||
console.log('\n---------- 纵向滚动 ---------')
|
||||
await measureScroll({ axis: 'y', steps: 150, px: 60, label: 'Y' })
|
||||
console.log('\n---------- 横向滚动 ---------')
|
||||
await measureScroll({ axis: 'x', steps: 100, px: 80, label: 'X' })
|
||||
|
||||
await profile('纵向滚动', () => measureScroll({ axis: 'y', steps: 200, px: 60, label: 'Y-prof' }))
|
||||
|
||||
console.log('\n---------- 点击 ---------')
|
||||
await measureClick({ label: '就绪态' })
|
||||
await profile('点击进入编辑', () => measureClick({ label: 'prof' }))
|
||||
|
||||
console.log('\n---------- 编辑态下的纵向滚动 ---------')
|
||||
await page.evaluate(() => {
|
||||
const td = [...document.querySelectorAll('.stk-tbody-main tr')][2]?.querySelectorAll('td')[6]
|
||||
if (!td) return
|
||||
const r = td.getBoundingClientRect()
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent
|
||||
td.dispatchEvent(
|
||||
new Ctor(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
button: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
await page.waitForTimeout(500)
|
||||
const ed = await page.evaluate(() => ({
|
||||
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
|
||||
selects: document.querySelectorAll('.g3-select').length,
|
||||
inputs: document.querySelectorAll('.g3-input').length,
|
||||
datePickers: document.querySelectorAll('.g3-date-picker').length,
|
||||
}))
|
||||
console.log('编辑态 DOM:', JSON.stringify(ed))
|
||||
await measureScroll({ axis: 'y', steps: 150, px: 60, label: 'Y-编辑态' })
|
||||
|
||||
await browser.close()
|
||||
Reference in new issue
Block a user