Files
vf_react/src/utils/performance/resourceMonitor.ts
2025-11-21 18:12:58 +08:00

436 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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