feat: 创建 Web Vitals 监控工具
This commit is contained in:
467
src/utils/performance/webVitals.ts
Normal file
467
src/utils/performance/webVitals.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user