feat: 创建资源加载监控工具
This commit is contained in:
435
src/utils/performance/resourceMonitor.ts
Normal file
435
src/utils/performance/resourceMonitor.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user