diff --git a/src/utils/performance/resourceMonitor.ts b/src/utils/performance/resourceMonitor.ts new file mode 100644 index 00000000..39463ca1 --- /dev/null +++ b/src/utils/performance/resourceMonitor.ts @@ -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; +} + +// ============================================================ +// 全局状态 +// ============================================================ + +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, +};