feat: 创建整合所有指标的 Hook
This commit is contained in:
291
src/hooks/useFirstScreenMetrics.ts
Normal file
291
src/hooks/useFirstScreenMetrics.ts
Normal file
@@ -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<FirstScreenMetrics | null>(null);
|
||||
|
||||
// 使用 ref 记录页面加载开始时间
|
||||
const pageLoadStartRef = useRef<number>(performance.now());
|
||||
const skeletonStartRef = useRef<number>(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<number>(performance.now());
|
||||
const skeletonEndRef = useRef<number | null>(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;
|
||||
Reference in New Issue
Block a user