Optimize initial loading performance
This commit is contained in:
236
scripts/measure-performance.mjs
Normal file
236
scripts/measure-performance.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/');
|
||||
const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json';
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
serverProcess = await ensurePreviewServer(targetUrl);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
||||
await context.addInitScript(() => window.localStorage.clear());
|
||||
const page = await context.newPage();
|
||||
|
||||
const titleStartedAt = Date.now();
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const titleMs = Date.now() - titleStartedAt;
|
||||
const titleEntries = await resourceEntries(page);
|
||||
|
||||
const storyStartedAt = Date.now();
|
||||
await page.mouse.click(962, 240);
|
||||
await waitForStoryReady(page);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const storyMs = Date.now() - storyStartedAt;
|
||||
const storyEntries = await resourceEntries(page);
|
||||
|
||||
const battleStartedAt = Date.now();
|
||||
await advanceStoryUntilBattle(page);
|
||||
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const battleMs = Date.now() - battleStartedAt;
|
||||
const battleEntries = await resourceEntries(page);
|
||||
|
||||
const titleNames = new Set(titleEntries.map((entry) => entry.name));
|
||||
const storyNames = new Set(storyEntries.map((entry) => entry.name));
|
||||
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
|
||||
const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name));
|
||||
const report = {
|
||||
targetUrl,
|
||||
measuredAt: new Date().toISOString(),
|
||||
timingsMs: {
|
||||
titleReady: titleMs,
|
||||
storyReadyFromNewGame: storyMs,
|
||||
firstBattleReadyFromStory: battleMs
|
||||
},
|
||||
build: buildStats(),
|
||||
firstLoad: segmentStats(titleEntries),
|
||||
storyTransition: segmentStats(storyOnly),
|
||||
battleTransition: segmentStats(battleOnly)
|
||||
};
|
||||
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`Performance report written to ${outputPath}`);
|
||||
console.log(JSON.stringify({
|
||||
timingsMs: report.timingsMs,
|
||||
firstLoad: report.firstLoad.summary,
|
||||
storyTransition: report.storyTransition.summary,
|
||||
battleTransition: report.battleTransition.summary,
|
||||
largestJs: report.build.javascript[0] ?? null
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
function withDebugParam(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', 'canvas');
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function waitForTitle(page) {
|
||||
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
return activeScenes.includes('TitleScene');
|
||||
}, undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function waitForStoryReady(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
||||
return activeScenes.includes('StoryScene') && story?.ready === true;
|
||||
}, undefined, { timeout: 120000 });
|
||||
}
|
||||
|
||||
async function advanceStoryUntilBattle(page) {
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
||||
if (activeScenes.includes('BattleScene')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(160);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBattleReady(page, battleId) {
|
||||
await page.waitForFunction((expectedBattleId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
state?.scene === 'BattleScene' &&
|
||||
state?.battleId === expectedBattleId &&
|
||||
state?.phase === 'idle' &&
|
||||
state?.mapBackgroundReady === true &&
|
||||
state?.resultVisible === false
|
||||
);
|
||||
}, battleId, { timeout: 120000 });
|
||||
}
|
||||
|
||||
async function resourceEntries(page) {
|
||||
return page.evaluate(() =>
|
||||
performance.getEntriesByType('resource').map((entry) => ({
|
||||
name: entry.name,
|
||||
initiatorType: entry.initiatorType,
|
||||
startTime: Math.round(entry.startTime),
|
||||
duration: Math.round(entry.duration),
|
||||
transferSize: entry.transferSize || 0,
|
||||
encodedBodySize: entry.encodedBodySize || 0
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function segmentStats(entries) {
|
||||
const encodedBytes = entries.reduce((sum, entry) => sum + entry.encodedBodySize, 0);
|
||||
const transferBytes = entries.reduce((sum, entry) => sum + entry.transferSize, 0);
|
||||
const byExt = {};
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const ext = extensionOf(entry.name);
|
||||
byExt[ext] ??= { count: 0, encodedKB: 0, transferKB: 0 };
|
||||
byExt[ext].count += 1;
|
||||
byExt[ext].encodedKB += bytesToKb(entry.encodedBodySize);
|
||||
byExt[ext].transferKB += bytesToKb(entry.transferSize);
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
requests: entries.length,
|
||||
encodedKB: bytesToKb(encodedBytes),
|
||||
transferKB: bytesToKb(transferBytes)
|
||||
},
|
||||
byExt,
|
||||
largest: [...entries]
|
||||
.sort((left, right) => right.encodedBodySize - left.encodedBodySize)
|
||||
.slice(0, 12)
|
||||
.map((entry) => ({
|
||||
file: entry.name.split('/').pop(),
|
||||
encodedKB: bytesToKb(entry.encodedBodySize),
|
||||
startMs: entry.startTime,
|
||||
durationMs: entry.duration
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function buildStats() {
|
||||
const assetsDir = join(process.cwd(), 'dist', 'assets');
|
||||
if (!existsSync(assetsDir)) {
|
||||
return { javascript: [], sourcemaps: [] };
|
||||
}
|
||||
|
||||
const assets = readdirSync(assetsDir)
|
||||
.map((file) => ({ file, sizeKB: bytesToKb(statSync(join(assetsDir, file)).size) }))
|
||||
.sort((left, right) => right.sizeKB - left.sizeKB);
|
||||
|
||||
return {
|
||||
javascript: assets.filter((asset) => asset.file.endsWith('.js')),
|
||||
sourcemaps: assets.filter((asset) => asset.file.endsWith('.map'))
|
||||
};
|
||||
}
|
||||
|
||||
function extensionOf(name) {
|
||||
const path = name.split('?')[0];
|
||||
return path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() ?? 'other';
|
||||
}
|
||||
|
||||
function bytesToKb(bytes) {
|
||||
return Math.round((bytes / 1024) * 10) / 10;
|
||||
}
|
||||
|
||||
async function ensurePreviewServer(url) {
|
||||
if (await canReach(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
|
||||
if (!isLocal) {
|
||||
throw new Error(`No server responded at ${url}`);
|
||||
}
|
||||
|
||||
const stderr = [];
|
||||
const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
if (await canReach(url)) {
|
||||
return child;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
child.kill();
|
||||
throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`);
|
||||
}
|
||||
|
||||
async function canReach(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user