/** * 资源加载监控工具 * 使用 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, };