feat: 创建 Web Vitals 监控工具

This commit is contained in:
zdl
2025-11-21 18:12:34 +08:00
parent d6c7d64e59
commit 5f76530e80

View File

@@ -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<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,
};