/** * 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, };