"use client"; import { Suspense, useEffect, useRef, useState } from "react"; import { usePathname, useSearchParams } from "next/navigation"; // 版本由 Footer(RSC)从 package.json 注入;未正式设计版本号时传入空串,显示占位 const PLACEHOLDER = "—"; interface Metrics { page: number; // 页面:整页加载/客户端导航总耗时 tpl: number; // 模板:服务端响应(SSR / RSC)耗时 } // 硬加载:页面=loadEventEnd,模板≈TTFB(localhost 下基本等于服务端生成时间) function readHardNav(): Metrics { const n = performance.getEntriesByType("navigation")[0] as | PerformanceNavigationTiming | undefined; if (!n) return { page: 0, tpl: 0 }; return { page: Math.round((n.loadEventEnd || n.domComplete || n.responseEnd) - n.startTime), tpl: Math.max(0, Math.round(n.responseStart - n.requestStart)), }; } function TimingInner({ version }: { version: string }) { const pathname = usePathname(); const search = useSearchParams().toString(); // 初始必须为 null:performance 仅客户端可读,直接参与首帧渲染会导致水合不一致 const [metrics, setMetrics] = useState(null); // 记录一次站内链接点击,作为客户端导航计时起点 const pending = useRef<{ start: number; path: string; search: string } | null>(null); // 挂载后读取硬加载指标;load 事件后 loadEventEnd 才有完整值,再补一次 useEffect(() => { setMetrics(readHardNav()); const apply = () => setMetrics(readHardNav()); if (document.readyState !== "complete") { window.addEventListener("load", apply, { once: true }); return () => window.removeEventListener("load", apply); } }, []); // 捕获阶段监听站内点击(不干预任何默认行为) useEffect(() => { const onClick = (e: MouseEvent) => { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.defaultPrevented) return; const a = (e.target as HTMLElement).closest("a"); if (!a || !a.href || a.target === "_blank" || a.hasAttribute("download")) return; let u: URL; try { u = new URL(a.href); } catch { return; } if (u.origin !== location.origin) return; pending.current = { start: performance.now(), path: u.pathname, search: u.search }; }; document.addEventListener("click", onClick, true); return () => document.removeEventListener("click", onClick, true); }, []); // 路由切换后两帧(绘制完成)结算;模板耗时取本次 RSC fetch 的资源条目 useEffect(() => { const p = pending.current; if (!p) return; if (p.path !== pathname || p.search !== (search ? `?${search}` : "")) return; let raf2 = 0; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { const page = Math.round(performance.now() - p.start); let tpl = 0; const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; for (let i = resources.length - 1; i >= 0; i--) { const r = resources[i]; if ( r.initiatorType === "fetch" && r.startTime >= p.start - 10 && r.name.startsWith(location.origin + p.path) ) { tpl = Math.round(r.responseEnd - r.startTime); break; } } setMetrics((prev) => ({ page: Math.max(1, page), tpl: tpl || prev?.tpl || 0 })); pending.current = null; }); }); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [pathname, search]); return ( 版本:{version || PLACEHOLDER} 页面:{metrics ? `${metrics.page}ms` : PLACEHOLDER} 模板:{metrics ? `${metrics.tpl}ms` : PLACEHOLDER} ); } export default function FooterTiming({ version = "" }: { version?: string }) { return ( 版本:{version || PLACEHOLDER} 页面:{PLACEHOLDER} 模板:{PLACEHOLDER} } > ); }