diff --git a/src/hooks/useFirstScreenMetrics.ts b/src/hooks/useFirstScreenMetrics.ts new file mode 100644 index 00000000..58b1afc6 --- /dev/null +++ b/src/hooks/useFirstScreenMetrics.ts @@ -0,0 +1,291 @@ +/** + * 首屏性能指标收集 Hook + * 整合 Web Vitals、资源加载、API 请求等指标 + * + * 使用示例: + * ```tsx + * const { metrics, isLoading, remeasure, exportMetrics } = useFirstScreenMetrics({ + * pageType: 'home', + * enableConsoleLog: process.env.NODE_ENV === 'development' + * }); + * ``` + * + * @module hooks/useFirstScreenMetrics + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals'; +import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor'; +import posthog from 'posthog-js'; +import type { + FirstScreenMetrics, + UseFirstScreenMetricsOptions, + UseFirstScreenMetricsResult, + FirstScreenInteractiveEventProperties, +} from '@/types/metrics'; + +// ============================================================ +// Hook 实现 +// ============================================================ + +/** + * 首屏性能指标收集 Hook + */ +export const useFirstScreenMetrics = ( + options: UseFirstScreenMetricsOptions +): UseFirstScreenMetricsResult => { + const { + pageType, + enableConsoleLog = process.env.NODE_ENV === 'development', + trackToPostHog = process.env.NODE_ENV === 'production', + customProperties = {}, + } = options; + + const [isLoading, setIsLoading] = useState(true); + const [metrics, setMetrics] = useState(null); + + // 使用 ref 记录页面加载开始时间 + const pageLoadStartRef = useRef(performance.now()); + const skeletonStartRef = useRef(performance.now()); + const hasInitializedRef = useRef(false); + + /** + * 收集所有首屏指标 + */ + const collectAllMetrics = useCallback((): FirstScreenMetrics => { + try { + // 1. 初始化 Web Vitals 监控 + initWebVitalsTracking({ + enableConsoleLog, + trackToPostHog: false, // Web Vitals 自己会上报,这里不重复 + pageType, + customProperties, + }); + + // 2. 获取 Web Vitals 指标(延迟获取,等待 LCP/FCP 等指标完成) + const webVitalsCache = getCachedMetrics(); + const webVitals = Object.fromEntries(webVitalsCache.entries()); + + // 3. 收集资源加载统计 + const resourceStats = collectResourceStats({ + enableConsoleLog, + trackToPostHog: false, // 避免重复上报 + pageType, + customProperties, + }); + + // 4. 收集 API 请求统计 + const apiStats = collectApiStats({ + enableConsoleLog, + trackToPostHog: false, + pageType, + customProperties, + }); + + // 5. 计算首屏可交互时间(TTI) + const now = performance.now(); + const timeToInteractive = now - pageLoadStartRef.current; + + // 6. 计算骨架屏展示时长 + const skeletonDisplayDuration = now - skeletonStartRef.current; + + const firstScreenMetrics: FirstScreenMetrics = { + webVitals, + resourceStats, + apiStats, + timeToInteractive, + skeletonDisplayDuration, + measuredAt: Date.now(), + }; + + return firstScreenMetrics; + } catch (error) { + console.error('Failed to collect first screen metrics:', error); + throw error; + } + }, [pageType, enableConsoleLog, trackToPostHog, customProperties]); + + /** + * 上报首屏可交互事件到 PostHog + */ + const trackFirstScreenInteractive = useCallback( + (metrics: FirstScreenMetrics) => { + if (!trackToPostHog || process.env.NODE_ENV !== 'production') { + return; + } + + try { + const eventProperties: FirstScreenInteractiveEventProperties = { + tti_seconds: metrics.timeToInteractive / 1000, + skeleton_duration_seconds: metrics.skeletonDisplayDuration / 1000, + api_request_count: metrics.apiStats.totalRequests, + api_avg_response_time_ms: metrics.apiStats.avgResponseTime, + page_type: pageType, + measured_at: metrics.measuredAt, + ...customProperties, + }; + + posthog.capture('First Screen Interactive', eventProperties); + + if (enableConsoleLog) { + console.log('📊 Tracked First Screen Interactive to PostHog', eventProperties); + } + } catch (error) { + console.error('Failed to track first screen interactive:', error); + } + }, + [pageType, trackToPostHog, enableConsoleLog, customProperties] + ); + + /** + * 手动触发重新测量 + */ + const remeasure = useCallback(() => { + setIsLoading(true); + + // 重置计时器 + pageLoadStartRef.current = performance.now(); + skeletonStartRef.current = performance.now(); + + // 延迟收集指标(等待 Web Vitals 完成) + setTimeout(() => { + try { + const newMetrics = collectAllMetrics(); + setMetrics(newMetrics); + trackFirstScreenInteractive(newMetrics); + + if (enableConsoleLog) { + console.group('🎯 First Screen Metrics (Re-measured)'); + console.log('TTI:', `${(newMetrics.timeToInteractive / 1000).toFixed(2)}s`); + console.log('Skeleton Duration:', `${(newMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`); + console.log('API Requests:', newMetrics.apiStats.totalRequests); + console.groupEnd(); + } + } catch (error) { + console.error('Failed to remeasure metrics:', error); + } finally { + setIsLoading(false); + } + }, 1000); // 延迟 1 秒收集 + }, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); + + /** + * 导出指标为 JSON + */ + const exportMetrics = useCallback((): string => { + if (!metrics) { + return JSON.stringify({ error: 'No metrics available' }, null, 2); + } + + return JSON.stringify(metrics, null, 2); + }, [metrics]); + + /** + * 初始化:在组件挂载时自动收集指标 + */ + useEffect(() => { + // 防止重复初始化 + if (hasInitializedRef.current) { + return; + } + + hasInitializedRef.current = true; + + if (enableConsoleLog) { + console.log('🚀 useFirstScreenMetrics initialized', { pageType }); + } + + // 延迟收集指标,等待页面渲染完成和 Web Vitals 指标就绪 + const timeoutId = setTimeout(() => { + try { + const firstScreenMetrics = collectAllMetrics(); + setMetrics(firstScreenMetrics); + trackFirstScreenInteractive(firstScreenMetrics); + + if (enableConsoleLog) { + console.group('🎯 First Screen Metrics'); + console.log('━'.repeat(50)); + console.log(`✅ TTI: ${(firstScreenMetrics.timeToInteractive / 1000).toFixed(2)}s`); + console.log(`✅ Skeleton Duration: ${(firstScreenMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`); + console.log(`✅ API Requests: ${firstScreenMetrics.apiStats.totalRequests}`); + console.log(`✅ API Avg Response: ${firstScreenMetrics.apiStats.avgResponseTime.toFixed(0)}ms`); + console.log('━'.repeat(50)); + console.groupEnd(); + } + } catch (error) { + console.error('Failed to collect initial metrics:', error); + } finally { + setIsLoading(false); + } + }, 2000); // 延迟 2 秒收集(确保 LCP/FCP 等指标已触发) + + // Cleanup + return () => { + clearTimeout(timeoutId); + }; + }, []); // 空依赖数组,只在挂载时执行一次 + + // ============================================================ + // 返回值 + // ============================================================ + + return { + isLoading, + metrics, + remeasure, + exportMetrics, + }; +}; + +// ============================================================ +// 辅助 Hook:标记骨架屏结束 +// ============================================================ + +/** + * 标记骨架屏结束的 Hook + * 用于在骨架屏消失时记录时间点 + * + * 使用示例: + * ```tsx + * const { markSkeletonEnd } = useSkeletonTiming(); + * + * useEffect(() => { + * if (!loading) { + * markSkeletonEnd(); + * } + * }, [loading, markSkeletonEnd]); + * ``` + */ +export const useSkeletonTiming = () => { + const skeletonStartRef = useRef(performance.now()); + const skeletonEndRef = useRef(null); + + const markSkeletonEnd = useCallback(() => { + if (!skeletonEndRef.current) { + skeletonEndRef.current = performance.now(); + const duration = skeletonEndRef.current - skeletonStartRef.current; + + if (process.env.NODE_ENV === 'development') { + console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`); + } + } + }, []); + + const getSkeletonDuration = useCallback((): number | null => { + if (skeletonEndRef.current) { + return skeletonEndRef.current - skeletonStartRef.current; + } + return null; + }, []); + + return { + markSkeletonEnd, + getSkeletonDuration, + }; +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default useFirstScreenMetrics;