🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
313 lines
9.5 KiB
TypeScript
313 lines
9.5 KiB
TypeScript
/**
|
||
* 首屏性能指标收集 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<FirstScreenMetrics | null>(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;
|