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