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