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