/** * 首屏性能指标收集 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 { performanceMonitor } from '@utils/performanceMonitor'; import { usePerformanceMark } from '@hooks/usePerformanceTracker'; 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 hasMarkedRef = useRef(false); const hasInitializedRef = useRef(false); // 在组件首次渲染时标记开始时间点 if (!hasMarkedRef.current) { hasMarkedRef.current = true; performanceMonitor.mark(`${pageType}-page-load-start`); performanceMonitor.mark(`${pageType}-skeleton-start`); } /** * 收集所有首屏指标 */ 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 performanceMonitor.mark(`${pageType}-interactive`); const timeToInteractive = performanceMonitor.measure( `${pageType}-page-load-start`, `${pageType}-interactive`, `${pageType} TTI` ) || 0; // 6. 计算骨架屏展示时长 const skeletonDisplayDuration = performanceMonitor.measure( `${pageType}-skeleton-start`, `${pageType}-interactive`, `${pageType} 骨架屏时长` ) || 0; 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); // 重置性能标记 performanceMonitor.mark(`${pageType}-page-load-start`); performanceMonitor.mark(`${pageType}-skeleton-start`); // 延迟收集指标(等待 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 秒收集 }, [pageType, 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('home-skeleton'); * * useEffect(() => { * if (!loading) { * markSkeletonEnd(); * } * }, [loading, markSkeletonEnd]); * ``` */ export const useSkeletonTiming = (prefix = 'skeleton') => { const { mark, getMeasure } = usePerformanceMark(prefix); const hasMarkedEndRef = useRef(false); const hasMarkedStartRef = useRef(false); // 在组件首次渲染时标记开始 if (!hasMarkedStartRef.current) { hasMarkedStartRef.current = true; mark('start'); } const markSkeletonEnd = useCallback(() => { if (!hasMarkedEndRef.current) { hasMarkedEndRef.current = true; mark('end'); const duration = getMeasure('start', 'end'); if (process.env.NODE_ENV === 'development' && duration) { console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`); } } }, [mark, getMeasure]); const getSkeletonDuration = useCallback((): number | null => { return getMeasure('start', 'end'); }, [getMeasure]); return { markSkeletonEnd, getSkeletonDuration, }; }; // ============================================================ // 默认导出 // ============================================================ export default useFirstScreenMetrics;