feat: 创建资源加载监控工具

This commit is contained in:
zdl
2025-11-21 18:12:58 +08:00
parent 5f76530e80
commit e34f5593b4

View File

@@ -0,0 +1,435 @@
/**
* 资源加载监控工具
* 使用 Performance API 监控 JS、CSS、图片、字体等资源的加载情况
*
* 功能:
* - 监控资源加载时间
* - 统计 bundle 大小
* - 计算缓存命中率
* - 监控 API 请求响应时间
*
* @module utils/performance/resourceMonitor
*/
import posthog from 'posthog-js';
import type {
ResourceTiming,
ResourceStats,
ResourceType,
ApiRequestStats,
ApiRequestTiming,
ResourceLoadEventProperties,
} from '@/types/metrics';
import {
calculateRating,
getRatingIcon,
getRatingConsoleColor,
BUNDLE_SIZE_THRESHOLDS,
RESOURCE_LOAD_TIME_THRESHOLDS,
CACHE_HIT_RATE_THRESHOLDS,
API_RESPONSE_TIME_THRESHOLDS,
} from '@constants/performanceThresholds';
// ============================================================
// 类型定义
// ============================================================
interface ResourceMonitorConfig {
/** 是否启用控制台日志 */
enableConsoleLog?: boolean;
/** 是否上报到 PostHog */
trackToPostHog?: boolean;
/** 页面类型 */
pageType?: string;
/** 自定义事件属性 */
customProperties?: Record<string, any>;
}
// ============================================================
// 全局状态
// ============================================================
let resourceStatsCache: ResourceStats | null = null;
let apiStatsCache: ApiRequestStats | null = null;
// ============================================================
// 资源类型判断
// ============================================================
/**
* 根据资源 URL 判断资源类型
*/
const getResourceType = (url: string, initiatorType: string): ResourceType => {
const extension = url.split('.').pop()?.toLowerCase() || '';
// 根据文件扩展名判断
if (['js', 'mjs', 'jsx'].includes(extension)) return 'script';
if (['css', 'scss', 'sass', 'less'].includes(extension)) return 'stylesheet';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico'].includes(extension)) return 'image';
if (['woff', 'woff2', 'ttf', 'otf', 'eot'].includes(extension)) return 'font';
if (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') return 'other'; // API 请求
if (url.includes('/api/')) return 'other'; // API 请求
if (extension === 'html' || initiatorType === 'navigation') return 'document';
return 'other';
};
/**
* 判断资源是否来自缓存
*/
const isFromCache = (entry: PerformanceResourceTiming): boolean => {
// transferSize 为 0 表示来自缓存(或 304 Not Modified
return entry.transferSize === 0 && entry.decodedBodySize > 0;
};
// ============================================================
// 资源统计
// ============================================================
/**
* 收集所有资源的加载信息
*/
export const collectResourceStats = (config: ResourceMonitorConfig = {}): ResourceStats => {
const defaultConfig: ResourceMonitorConfig = {
enableConsoleLog: process.env.NODE_ENV === 'development',
trackToPostHog: process.env.NODE_ENV === 'production',
pageType: 'unknown',
...config,
};
try {
// 获取所有资源条目
const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const resources: ResourceTiming[] = [];
let totalJsSize = 0;
let totalCssSize = 0;
let totalImageSize = 0;
let totalFontSize = 0;
let totalLoadTime = 0;
let cacheHits = 0;
resourceEntries.forEach((entry) => {
const type = getResourceType(entry.name, entry.initiatorType);
const fromCache = isFromCache(entry);
const size = entry.transferSize || entry.encodedBodySize || 0;
const duration = entry.duration;
const resourceTiming: ResourceTiming = {
name: entry.name,
type,
size,
duration,
startTime: entry.startTime,
fromCache,
};
resources.push(resourceTiming);
// 统计各类资源的总大小
switch (type) {
case 'script':
totalJsSize += size;
break;
case 'stylesheet':
totalCssSize += size;
break;
case 'image':
totalImageSize += size;
break;
case 'font':
totalFontSize += size;
break;
}
totalLoadTime += duration;
if (fromCache) {
cacheHits++;
}
});
const cacheHitRate = resources.length > 0 ? (cacheHits / resources.length) * 100 : 0;
const stats: ResourceStats = {
totalJsSize,
totalCssSize,
totalImageSize,
totalFontSize,
totalLoadTime,
cacheHitRate,
resources,
};
// 缓存结果
resourceStatsCache = stats;
// 控制台输出
if (defaultConfig.enableConsoleLog) {
logResourceStatsToConsole(stats);
}
// 上报到 PostHog
if (defaultConfig.trackToPostHog && process.env.NODE_ENV === 'production') {
trackResourceStatsToPostHog(stats, defaultConfig);
}
return stats;
} catch (error) {
console.error('Failed to collect resource stats:', error);
return {
totalJsSize: 0,
totalCssSize: 0,
totalImageSize: 0,
totalFontSize: 0,
totalLoadTime: 0,
cacheHitRate: 0,
resources: [],
};
}
};
/**
* 控制台输出资源统计
*/
const logResourceStatsToConsole = (stats: ResourceStats): void => {
console.group('📦 Resource Loading Statistics');
console.log('━'.repeat(50));
// JS Bundle
const jsRating = calculateRating(stats.totalJsSize / 1024, BUNDLE_SIZE_THRESHOLDS.js);
const jsColor = getRatingConsoleColor(jsRating);
const jsIcon = getRatingIcon(jsRating);
console.log(
`${jsIcon} ${jsColor}JS Bundle: ${(stats.totalJsSize / 1024).toFixed(2)}KB (${jsRating})\x1b[0m`
);
// CSS Bundle
const cssRating = calculateRating(stats.totalCssSize / 1024, BUNDLE_SIZE_THRESHOLDS.css);
const cssColor = getRatingConsoleColor(cssRating);
const cssIcon = getRatingIcon(cssRating);
console.log(
`${cssIcon} ${cssColor}CSS Bundle: ${(stats.totalCssSize / 1024).toFixed(2)}KB (${cssRating})\x1b[0m`
);
// Images
const imageRating = calculateRating(stats.totalImageSize / 1024, BUNDLE_SIZE_THRESHOLDS.image);
const imageColor = getRatingConsoleColor(imageRating);
const imageIcon = getRatingIcon(imageRating);
console.log(
`${imageIcon} ${imageColor}Images: ${(stats.totalImageSize / 1024).toFixed(2)}KB (${imageRating})\x1b[0m`
);
// Fonts
console.log(`📝 Fonts: ${(stats.totalFontSize / 1024).toFixed(2)}KB`);
// Total Load Time
const loadTimeRating = calculateRating(stats.totalLoadTime, RESOURCE_LOAD_TIME_THRESHOLDS);
const loadTimeColor = getRatingConsoleColor(loadTimeRating);
const loadTimeIcon = getRatingIcon(loadTimeRating);
console.log(
`${loadTimeIcon} ${loadTimeColor}Total Load Time: ${(stats.totalLoadTime / 1000).toFixed(2)}s (${loadTimeRating})\x1b[0m`
);
// Cache Hit Rate
const cacheRating = calculateRating(stats.cacheHitRate, CACHE_HIT_RATE_THRESHOLDS, true);
const cacheColor = getRatingConsoleColor(cacheRating);
const cacheIcon = getRatingIcon(cacheRating);
console.log(
`${cacheIcon} ${cacheColor}Cache Hit Rate: ${stats.cacheHitRate.toFixed(1)}% (${cacheRating})\x1b[0m`
);
console.log('━'.repeat(50));
console.groupEnd();
};
/**
* 上报资源统计到 PostHog
*/
const trackResourceStatsToPostHog = (
stats: ResourceStats,
config: ResourceMonitorConfig
): void => {
try {
const eventProperties: ResourceLoadEventProperties = {
js_size_kb: stats.totalJsSize / 1024,
css_size_kb: stats.totalCssSize / 1024,
image_size_kb: stats.totalImageSize / 1024,
total_load_time_s: stats.totalLoadTime / 1000,
cache_hit_rate_percent: stats.cacheHitRate,
resource_count: stats.resources.length,
page_type: config.pageType || 'unknown',
measured_at: Date.now(),
...config.customProperties,
};
posthog.capture('Resource Load Complete', eventProperties);
} catch (error) {
console.error('Failed to track resource stats to PostHog:', error);
}
};
// ============================================================
// API 请求监控
// ============================================================
/**
* 收集 API 请求统计
*/
export const collectApiStats = (config: ResourceMonitorConfig = {}): ApiRequestStats => {
try {
const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
// 筛选 API 请求(通过 URL 包含 /api/ 或 initiatorType 为 fetch/xmlhttprequest
const apiEntries = resourceEntries.filter(
(entry) =>
entry.name.includes('/api/') ||
entry.initiatorType === 'fetch' ||
entry.initiatorType === 'xmlhttprequest'
);
const requests: ApiRequestTiming[] = apiEntries.map((entry) => ({
url: entry.name,
method: 'GET', // Performance API 无法获取方法,默认 GET
duration: entry.duration,
status: 200, // Performance API 无法获取状态码,假设成功
success: true,
startTime: entry.startTime,
}));
const totalRequests = requests.length;
const avgResponseTime =
totalRequests > 0
? requests.reduce((sum, req) => sum + req.duration, 0) / totalRequests
: 0;
const slowestRequest =
totalRequests > 0 ? Math.max(...requests.map((req) => req.duration)) : 0;
const failedRequests = 0; // Performance API 无法判断失败
const stats: ApiRequestStats = {
totalRequests,
avgResponseTime,
slowestRequest,
failedRequests,
requests,
};
// 缓存结果
apiStatsCache = stats;
// 控制台输出
if (config.enableConsoleLog) {
logApiStatsToConsole(stats);
}
return stats;
} catch (error) {
console.error('Failed to collect API stats:', error);
return {
totalRequests: 0,
avgResponseTime: 0,
slowestRequest: 0,
failedRequests: 0,
requests: [],
};
}
};
/**
* 控制台输出 API 统计
*/
const logApiStatsToConsole = (stats: ApiRequestStats): void => {
console.group('🌐 API Request Statistics');
console.log('━'.repeat(50));
console.log(`📊 Total Requests: ${stats.totalRequests}`);
// Average Response Time
const avgRating = calculateRating(stats.avgResponseTime, API_RESPONSE_TIME_THRESHOLDS);
const avgColor = getRatingConsoleColor(avgRating);
const avgIcon = getRatingIcon(avgRating);
console.log(
`${avgIcon} ${avgColor}Avg Response Time: ${stats.avgResponseTime.toFixed(0)}ms (${avgRating})\x1b[0m`
);
// Slowest Request
const slowestRating = calculateRating(stats.slowestRequest, API_RESPONSE_TIME_THRESHOLDS);
const slowestColor = getRatingConsoleColor(slowestRating);
const slowestIcon = getRatingIcon(slowestRating);
console.log(
`${slowestIcon} ${slowestColor}Slowest Request: ${stats.slowestRequest.toFixed(0)}ms (${slowestRating})\x1b[0m`
);
console.log(`❌ Failed Requests: ${stats.failedRequests}`);
console.log('━'.repeat(50));
console.groupEnd();
};
// ============================================================
// 导出工具函数
// ============================================================
/**
* 获取缓存的资源统计
*/
export const getCachedResourceStats = (): ResourceStats | null => {
return resourceStatsCache;
};
/**
* 获取缓存的 API 统计
*/
export const getCachedApiStats = (): ApiRequestStats | null => {
return apiStatsCache;
};
/**
* 清除缓存
*/
export const clearStatsCache = (): void => {
resourceStatsCache = null;
apiStatsCache = null;
};
/**
* 导出资源统计为 JSON
*/
export const exportResourceStatsAsJSON = (): string => {
return JSON.stringify(resourceStatsCache, null, 2);
};
/**
* 导出 API 统计为 JSON
*/
export const exportApiStatsAsJSON = (): string => {
return JSON.stringify(apiStatsCache, null, 2);
};
/**
* 在控制台输出完整报告
*/
export const logPerformanceReport = (): void => {
if (resourceStatsCache) {
logResourceStatsToConsole(resourceStatsCache);
}
if (apiStatsCache) {
logApiStatsToConsole(apiStatsCache);
}
};
// ============================================================
// 默认导出
// ============================================================
export default {
collectResourceStats,
collectApiStats,
getCachedResourceStats,
getCachedApiStats,
clearStatsCache,
exportResourceStatsAsJSON,
exportApiStatsAsJSON,
logPerformanceReport,
};