diff --git a/src/utils/performance/webVitals.ts b/src/utils/performance/webVitals.ts new file mode 100644 index 00000000..57769b1e --- /dev/null +++ b/src/utils/performance/webVitals.ts @@ -0,0 +1,467 @@ +/** + * 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; +} + +interface PerformanceMetric { + name: string; + value: number; + rating: MetricRating; + entries: any[]; +} + +// ============================================================ +// 全局状态 +// ============================================================ + +let metricsCache: Map = 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 => { + 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, +};