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