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

468 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.

/**
* Web Vitals 性能监控工具
* 使用 PostHog 内置的性能监控 API无需单独安装 web-vitals 库
*
* 支持的指标:
* - LCP (Largest Contentful Paint) - 最大内容绘制
* - FCP (First Contentful Paint) - 首次内容绘制
* - CLS (Cumulative Layout Shift) - 累积布局偏移
* - FID (First Input Delay) - 首次输入延迟
* - TTFB (Time to First Byte) - 首字节时间
*
* @module utils/performance/webVitals
*/
import posthog from 'posthog-js';
import type { WebVitalMetric, MetricRating, WebVitalsEventProperties } from '@/types/metrics';
import {
calculateRating,
getRatingIcon,
getRatingConsoleColor,
formatMetricValue,
LCP_THRESHOLDS,
FCP_THRESHOLDS,
CLS_THRESHOLDS,
FID_THRESHOLDS,
TTFB_THRESHOLDS,
} from '@constants/performanceThresholds';
// ============================================================
// 类型定义
// ============================================================
interface WebVitalsConfig {
/** 是否启用控制台日志 (开发环境推荐) */
enableConsoleLog?: boolean;
/** 是否上报到 PostHog (生产环境推荐) */
trackToPostHog?: boolean;
/** 页面类型 (用于区分不同页面) */
pageType?: string;
/** 自定义事件属性 */
customProperties?: Record<string, any>;
}
interface PerformanceMetric {
name: string;
value: number;
rating: MetricRating;
entries: any[];
}
// ============================================================
// 全局状态
// ============================================================
let metricsCache: Map<string, WebVitalMetric> = new Map();
let isObserving = false;
// ============================================================
// 核心函数
// ============================================================
/**
* 获取阈值配置
*/
const getThresholds = (metricName: string) => {
switch (metricName) {
case 'LCP':
return LCP_THRESHOLDS;
case 'FCP':
return FCP_THRESHOLDS;
case 'CLS':
return CLS_THRESHOLDS;
case 'FID':
return FID_THRESHOLDS;
case 'TTFB':
return TTFB_THRESHOLDS;
default:
return { good: 0, needsImprovement: 0 };
}
};
/**
* 处理性能指标
*/
const handleMetric = (
metric: PerformanceMetric,
config: WebVitalsConfig
): WebVitalMetric => {
const { name, value, rating: browserRating } = metric;
const thresholds = getThresholds(name);
const rating = calculateRating(value, thresholds);
const webVitalMetric: WebVitalMetric = {
name,
value,
rating,
timestamp: Date.now(),
};
// 缓存指标
metricsCache.set(name, webVitalMetric);
// 控制台输出 (开发环境)
if (config.enableConsoleLog) {
logMetricToConsole(webVitalMetric);
}
// 上报到 PostHog (生产环境)
if (config.trackToPostHog && process.env.NODE_ENV === 'production') {
trackMetricToPostHog(webVitalMetric, config);
}
return webVitalMetric;
};
/**
* 控制台输出指标
*/
const logMetricToConsole = (metric: WebVitalMetric): void => {
const color = getRatingConsoleColor(metric.rating);
const icon = getRatingIcon(metric.rating);
const formattedValue = formatMetricValue(metric.name, metric.value);
const reset = '\x1b[0m';
console.log(
`${icon} ${color}${metric.name}: ${formattedValue} (${metric.rating})${reset}`
);
};
/**
* 上报指标到 PostHog
*/
const trackMetricToPostHog = (
metric: WebVitalMetric,
config: WebVitalsConfig
): void => {
try {
const eventProperties: WebVitalsEventProperties = {
metric_name: metric.name as any,
metric_value: metric.value,
metric_rating: metric.rating,
page_type: config.pageType || 'unknown',
device_type: getDeviceType(),
network_type: getNetworkType(),
browser: getBrowserInfo(),
is_authenticated: checkIfAuthenticated(),
measured_at: metric.timestamp,
...config.customProperties,
};
posthog.capture(`Web Vitals - ${metric.name}`, eventProperties);
} catch (error) {
console.error('Failed to track metric to PostHog:', error);
}
};
// ============================================================
// Performance Observer API (核心监控)
// ============================================================
/**
* 初始化 Web Vitals 监控
* 使用浏览器原生 Performance Observer API
*/
export const initWebVitalsTracking = (config: WebVitalsConfig = {}): void => {
// 防止重复初始化
if (isObserving) {
console.warn('⚠️ Web Vitals tracking already initialized');
return;
}
// 检查浏览器支持
if (typeof window === 'undefined' || !('PerformanceObserver' in window)) {
console.warn('⚠️ PerformanceObserver not supported');
return;
}
const defaultConfig: WebVitalsConfig = {
enableConsoleLog: process.env.NODE_ENV === 'development',
trackToPostHog: process.env.NODE_ENV === 'production',
pageType: 'unknown',
...config,
};
isObserving = true;
if (defaultConfig.enableConsoleLog) {
console.group('🚀 Web Vitals Performance Tracking');
console.log('Page Type:', defaultConfig.pageType);
console.log('Console Log:', defaultConfig.enableConsoleLog);
console.log('PostHog Tracking:', defaultConfig.trackToPostHog);
console.groupEnd();
}
// 监控 LCP (Largest Contentful Paint)
observeLCP(defaultConfig);
// 监控 FCP (First Contentful Paint)
observeFCP(defaultConfig);
// 监控 CLS (Cumulative Layout Shift)
observeCLS(defaultConfig);
// 监控 FID (First Input Delay)
observeFID(defaultConfig);
// 监控 TTFB (Time to First Byte)
observeTTFB(defaultConfig);
};
/**
* 监控 LCP - Largest Contentful Paint
*/
const observeLCP = (config: WebVitalsConfig): void => {
try {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
handleMetric(
{
name: 'LCP',
value: lastEntry.startTime,
rating: 'good',
entries: entries,
},
config
);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (error) {
console.error('Failed to observe LCP:', error);
}
};
/**
* 监控 FCP - First Contentful Paint
*/
const observeFCP = (config: WebVitalsConfig): void => {
try {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach((entry) => {
if (entry.name === 'first-contentful-paint') {
handleMetric(
{
name: 'FCP',
value: entry.startTime,
rating: 'good',
entries: [entry],
},
config
);
}
});
});
observer.observe({ type: 'paint', buffered: true });
} catch (error) {
console.error('Failed to observe FCP:', error);
}
};
/**
* 监控 CLS - Cumulative Layout Shift
*/
const observeCLS = (config: WebVitalsConfig): void => {
try {
let clsValue = 0;
let clsEntries: any[] = [];
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach((entry: any) => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
}
});
handleMetric(
{
name: 'CLS',
value: clsValue,
rating: 'good',
entries: clsEntries,
},
config
);
});
observer.observe({ type: 'layout-shift', buffered: true });
} catch (error) {
console.error('Failed to observe CLS:', error);
}
};
/**
* 监控 FID - First Input Delay
*/
const observeFID = (config: WebVitalsConfig): void => {
try {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const firstInput = entries[0];
if (firstInput) {
const fidValue = (firstInput as any).processingStart - firstInput.startTime;
handleMetric(
{
name: 'FID',
value: fidValue,
rating: 'good',
entries: [firstInput],
},
config
);
}
});
observer.observe({ type: 'first-input', buffered: true });
} catch (error) {
console.error('Failed to observe FID:', error);
}
};
/**
* 监控 TTFB - Time to First Byte
*/
const observeTTFB = (config: WebVitalsConfig): void => {
try {
const navigationEntry = performance.getEntriesByType('navigation')[0] as any;
if (navigationEntry) {
const ttfb = navigationEntry.responseStart - navigationEntry.requestStart;
handleMetric(
{
name: 'TTFB',
value: ttfb,
rating: 'good',
entries: [navigationEntry],
},
config
);
}
} catch (error) {
console.error('Failed to measure TTFB:', error);
}
};
// ============================================================
// 辅助函数
// ============================================================
/**
* 获取设备类型
*/
const getDeviceType = (): string => {
const ua = navigator.userAgent;
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
return 'tablet';
}
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
return 'mobile';
}
return 'desktop';
};
/**
* 获取网络类型
*/
const getNetworkType = (): string => {
const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
return connection?.effectiveType || 'unknown';
};
/**
* 获取浏览器信息
*/
const getBrowserInfo = (): string => {
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Edge')) return 'Edge';
return 'Unknown';
};
/**
* 检查用户是否已登录
*/
const checkIfAuthenticated = (): boolean => {
// 从 localStorage 或 cookie 中检查认证状态
return !!localStorage.getItem('has_visited'); // 示例逻辑
};
// ============================================================
// 导出工具函数
// ============================================================
/**
* 获取缓存的指标
*/
export const getCachedMetrics = (): Map<string, WebVitalMetric> => {
return metricsCache;
};
/**
* 获取单个指标
*/
export const getCachedMetric = (metricName: string): WebVitalMetric | undefined => {
return metricsCache.get(metricName);
};
/**
* 清除缓存
*/
export const clearMetricsCache = (): void => {
metricsCache.clear();
};
/**
* 导出所有指标为 JSON
*/
export const exportMetricsAsJSON = (): string => {
const metrics = Array.from(metricsCache.entries()).map(([key, value]) => ({
name: key,
...value,
}));
return JSON.stringify(metrics, null, 2);
};
/**
* 在控制台输出完整报告
*/
export const logPerformanceReport = (): void => {
console.group('📊 Web Vitals Performance Report');
console.log('━'.repeat(50));
metricsCache.forEach((metric) => {
logMetricToConsole(metric);
});
console.log('━'.repeat(50));
console.groupEnd();
};
// ============================================================
// 默认导出
// ============================================================
export default {
initWebVitalsTracking,
getCachedMetrics,
getCachedMetric,
clearMetricsCache,
exportMetricsAsJSON,
logPerformanceReport,
};