468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|