Optimize first load: defer mid-page kits, idle chrome, lighter canvas, self-hosted Outfit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 03:50:50 -05:00
parent 9a6474a69a
commit f30a7cf879
11 changed files with 176 additions and 126 deletions

View File

@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
poweredByHeader: false,
compress: true,
async rewrites() { async rewrites() {
return [ return [
{ {

View File

@ -8,6 +8,16 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "性能优化",
items: [
"首页行程/计算器等中屏板块改为视口内按需加载;匹配弹窗与对比弹窗按需拆包",
"闲时再挂载引导/反馈等 chrome;去掉全局 MutationObserver 开销",
"粒子画布延后启动、去掉连线计算;Loader 更快退出",
"Outfit 自托管 + 系统中文字体,去掉阻塞的 Google Fonts 外链",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "计划深化", tag: "计划深化",

View File

@ -20,8 +20,8 @@
--radius-md: 16px; --radius-md: 16px;
--radius-lg: 24px; --radius-lg: 24px;
--radius-xl: 32px; --radius-xl: 32px;
--font-display: 'Outfit', 'Noto Sans SC', sans-serif; --font-display: var(--font-outfit), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--font-body: 'Noto Sans SC', 'Outfit', sans-serif; --font-body: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", var(--font-outfit), sans-serif;
--nav-height: 72px; --nav-height: 72px;
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }

View File

@ -1,10 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Outfit } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/lib/auth"; import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast"; import { ToastProvider } from "@/lib/toast";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com"; const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
/** Display font self-hosted; CJK uses system stack to avoid multi‑MB webfont. */
const outfit = Outfit({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-outfit",
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE), metadataBase: new URL(SITE),
title: "nomadro · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
@ -28,12 +37,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" className={outfit.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
</head>
<body> <body>
<AuthProvider> <AuthProvider>
<ToastProvider>{children}</ToastProvider> <ToastProvider>{children}</ToastProvider>

View File

@ -267,12 +267,3 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
</div> </div>
); );
} }
export function MatcherTrigger({ onClick }: { onClick: () => void }) {
return (
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
<span className="matcher-fab-icon">🎯</span>
<span className="matcher-fab-text">智能匹配</span>
</button>
);
}

View File

@ -14,26 +14,20 @@ import WorldMap from "./WorldMap";
import Destinations from "./Destinations"; import Destinations from "./Destinations";
import GlobalSearch from "./GlobalSearch"; import GlobalSearch from "./GlobalSearch";
import Footer from "./Footer"; import Footer from "./Footer";
import CompareModal from "./CompareModal";
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
import ScrollProgress from "./ScrollProgress"; import ScrollProgress from "./ScrollProgress";
import SectionNav from "./SectionNav"; import SectionNav from "./SectionNav";
import CookieConsent from "./CookieConsent";
import NomadTips from "./NomadTips";
import DailyQuote from "./DailyQuote";
import ToolsStrip from "./ToolsStrip";
import FeedbackWidget from "./FeedbackWidget";
import OnboardingTour from "./OnboardingTour";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop"; import BackToTop from "./BackToTop";
import TripPlanner from "./TripPlanner"; import MatcherTrigger from "./MatcherTrigger";
import Calculator from "./Calculator";
import SpinGlobe from "./SpinGlobe";
import WhenVisible from "./WhenVisible"; import WhenVisible from "./WhenVisible";
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) => const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
dynamic(loader, { ssr: false, loading: () => null }); dynamic(loader, { ssr: false, loading: () => null });
const SpinGlobe = lazy(() => import("./SpinGlobe"));
const DailyQuote = lazy(() => import("./DailyQuote"));
const ToolsStrip = lazy(() => import("./ToolsStrip"));
const TripPlanner = lazy(() => import("./TripPlanner"));
const Calculator = lazy(() => import("./Calculator"));
const TimezoneBoard = lazy(() => import("./TimezoneBoard")); const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
const JetLagEstimator = lazy(() => import("./JetLagEstimator")); const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
const CostCompare = lazy(() => import("./CostCompare")); const CostCompare = lazy(() => import("./CostCompare"));
@ -89,6 +83,13 @@ const Tools = lazy(() => import("./Tools"));
const BlogSection = lazy(() => import("./BlogSection")); const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection")); const FAQSection = lazy(() => import("./FAQSection"));
const Community = lazy(() => import("./Community")); const Community = lazy(() => import("./Community"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
const CompareModal = lazy(() => import("./CompareModal"));
const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
const OnboardingTour = lazy(() => import("./OnboardingTour"));
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
interface Props { interface Props {
stats: Stats; stats: Stats;
@ -107,6 +108,7 @@ export default function HomeClient(props: Props) {
const [compareResults, setCompareResults] = useState<Destination[] | null>(null); const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
const [compareOpen, setCompareOpen] = useState(false); const [compareOpen, setCompareOpen] = useState(false);
const [matcherOpen, setMatcherOpen] = useState(false); const [matcherOpen, setMatcherOpen] = useState(false);
const [chromeReady, setChromeReady] = useState(false);
useEffect(() => { useEffect(() => {
const onMatcher = () => setMatcherOpen(true); const onMatcher = () => setMatcherOpen(true);
@ -115,6 +117,21 @@ export default function HomeClient(props: Props) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const start = () => setChromeReady(true);
const ric = window.requestIdleCallback
? window.requestIdleCallback(start, { timeout: 2800 })
: 0;
const t = window.setTimeout(start, 1200);
return () => {
clearTimeout(t);
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
};
}, []);
useEffect(() => {
// Above-fold reveal only — deferred sections self-reveal via WhenVisible
const nodes = document.querySelectorAll(".reveal:not(.visible)");
if (!nodes.length) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { (entries) => entries.forEach((e) => {
if (e.isIntersecting) { if (e.isIntersecting) {
@ -122,33 +139,13 @@ export default function HomeClient(props: Props) {
observer.unobserve(e.target); observer.unobserve(e.target);
} }
}), }),
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" } { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
); );
nodes.forEach((el, i) => {
let scheduled = 0; (el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}s`;
const observeAll = () => { observer.observe(el);
document.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => { });
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`; return () => observer.disconnect();
observer.observe(el);
});
};
const schedule = () => {
if (scheduled) return;
scheduled = window.setTimeout(() => {
scheduled = 0;
observeAll();
}, 80);
};
observeAll();
const mo = new MutationObserver(schedule);
mo.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
mo.disconnect();
if (scheduled) clearTimeout(scheduled);
};
}, []); }, []);
const toggleCompare = (slug: string) => { const toggleCompare = (slug: string) => {
@ -180,13 +177,13 @@ export default function HomeClient(props: Props) {
<Hero stats={props.stats} /> <Hero stats={props.stats} />
<WorldMap destinations={props.destinations} /> <WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} /> <Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
<SpinGlobe destinations={props.destinations} /> <WhenVisible minHeight={280}><SpinGlobe destinations={props.destinations} /></WhenVisible>
<DailyQuote /> <WhenVisible minHeight={120}><DailyQuote /></WhenVisible>
<ToolsStrip /> <WhenVisible minHeight={140}><ToolsStrip /></WhenVisible>
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible> <WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible> <WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
<TripPlanner destinations={props.destinations} /> <WhenVisible minHeight={420}><TripPlanner destinations={props.destinations} /></WhenVisible>
<Calculator destinations={props.destinations} /> <WhenVisible minHeight={360}><Calculator destinations={props.destinations} /></WhenVisible>
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible> <WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible> <WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible> <WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
@ -244,26 +241,32 @@ export default function HomeClient(props: Props) {
<BackToTop /> <BackToTop />
<SectionNav /> <SectionNav />
<MatcherTrigger onClick={() => setMatcherOpen(true)} /> <MatcherTrigger onClick={() => setMatcherOpen(true)} />
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} /> {matcherOpen && (
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} /> <DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
<CookieConsent /> )}
<NomadTips /> {chromeReady && (
<FeedbackWidget /> <>
<OnboardingTour /> <KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
<CookieConsent />
<NomadTips />
<FeedbackWidget />
<OnboardingTour />
</>
)}
{compareList.length > 0 && ( {compareList.length > 0 && (
<div className="compare-bar"> <div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span> <span>⚖️ 已选 {compareList.length}/4</span>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button> <button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button>
<Link <Link
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"} href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`} className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
aria-disabled={compareList.length < 2} aria-disabled={compareList.length < 2}
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }} onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
> >
完整对比页 完整对比页
</Link> </Link>
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button> <button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
</div> </div>
)} )}

View File

@ -2,14 +2,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/** Brief brand splash — exits ASAP so LCP isn't blocked. */
export default function Loader() { export default function Loader() {
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [gone, setGone] = useState(false); const [gone, setGone] = useState(false);
useEffect(() => { useEffect(() => {
const hide = () => setDone(true);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const delay = reduced ? 0 : 380; const delay = reduced ? 0 : document.readyState === "complete" ? 40 : 120;
const hide = () => setDone(true);
if (document.readyState === "complete") { if (document.readyState === "complete") {
const t = setTimeout(hide, delay); const t = setTimeout(hide, delay);
@ -18,7 +20,7 @@ export default function Loader() {
const onLoad = () => setTimeout(hide, delay); const onLoad = () => setTimeout(hide, delay);
window.addEventListener("load", onLoad); window.addEventListener("load", onLoad);
const fallback = setTimeout(hide, reduced ? 200 : 1200); const fallback = setTimeout(hide, reduced ? 120 : 600);
return () => { return () => {
window.removeEventListener("load", onLoad); window.removeEventListener("load", onLoad);
clearTimeout(fallback); clearTimeout(fallback);
@ -27,7 +29,7 @@ export default function Loader() {
useEffect(() => { useEffect(() => {
if (!done) return; if (!done) return;
const t = setTimeout(() => setGone(true), 500); const t = setTimeout(() => setGone(true), 280);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [done]); }, [done]);

View File

@ -0,0 +1,11 @@
"use client";
/** Lightweight FAB — kept separate so DestinationMatcher can be code-split. */
export default function MatcherTrigger({ onClick }: { onClick: () => void }) {
return (
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
<span className="matcher-fab-icon">🎯</span>
<span className="matcher-fab-text">智能匹配</span>
</button>
);
}

View File

@ -2,13 +2,14 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
/** Ambient particle field — deferred start, no O(n²) links, pauses when hidden. */
export default function ParticleCanvas() { export default function ParticleCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@ -18,85 +19,95 @@ export default function ParticleCanvas() {
} }
let animId = 0; let animId = 0;
let running = true; let running = false;
let started = false;
let resizeTimer = 0;
let particles: { let particles: {
x: number; y: number; size: number; x: number; y: number; size: number;
speedX: number; speedY: number; opacity: number; hue: number; speedX: number; speedY: number; opacity: number; hue: number;
}[] = []; }[] = [];
const resize = () => { const resize = () => {
canvas.width = window.innerWidth; const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.height = window.innerHeight; const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}; };
const create = () => { const create = () => {
const count = Math.min(Math.floor(window.innerWidth / 28), 48); const w = window.innerWidth;
const count = Math.min(Math.floor(w / 40), 28);
particles = Array.from({ length: count }, () => ({ particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width, x: Math.random() * w,
y: Math.random() * canvas.height, y: Math.random() * window.innerHeight,
size: Math.random() * 2 + 0.5, size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.25, speedX: (Math.random() - 0.5) * 0.22,
speedY: (Math.random() - 0.5) * 0.25, speedY: (Math.random() - 0.5) * 0.22,
opacity: Math.random() * 0.4 + 0.1, opacity: Math.random() * 0.35 + 0.08,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)], hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
})); }));
}; };
const onResize = () => {
resize();
create();
};
const draw = () => { const draw = () => {
if (!running) return; if (!running) return;
ctx.clearRect(0, 0, canvas.width, canvas.height); const w = window.innerWidth;
const linkDist = window.innerWidth < 768 ? 0 : 90; const h = window.innerHeight;
particles.forEach((p, i) => { ctx.clearRect(0, 0, w, h);
for (const p of particles) {
p.x += p.speedX; p.x += p.speedX;
p.y += p.speedY; p.y += p.speedY;
if (p.x < 0) p.x = canvas.width; if (p.x < 0) p.x = w;
if (p.x > canvas.width) p.x = 0; if (p.x > w) p.x = 0;
if (p.y < 0) p.y = canvas.height; if (p.y < 0) p.y = h;
if (p.y > canvas.height) p.y = 0; if (p.y > h) p.y = 0;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`; ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill(); ctx.fill();
}
if (linkDist > 0) {
for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j];
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.hypot(dx, dy);
if (dist < linkDist) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
});
animId = requestAnimationFrame(draw); animId = requestAnimationFrame(draw);
}; };
const onVis = () => { const start = () => {
if (started) return;
started = true;
resize();
create();
running = document.visibilityState === "visible"; running = document.visibilityState === "visible";
if (running) draw(); if (running) draw();
};
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
resize();
create();
}, 200);
};
const onVis = () => {
running = document.visibilityState === "visible" && started;
if (running) draw();
else cancelAnimationFrame(animId); else cancelAnimationFrame(animId);
}; };
resize(); const ric = window.requestIdleCallback
create(); ? window.requestIdleCallback(() => start(), { timeout: 1800 })
draw(); : 0;
window.addEventListener("resize", onResize); const fallback = window.setTimeout(start, 400);
window.addEventListener("resize", onResize, { passive: true });
document.addEventListener("visibilitychange", onVis); document.addEventListener("visibilitychange", onVis);
return () => { return () => {
running = false;
cancelAnimationFrame(animId); cancelAnimationFrame(animId);
clearTimeout(fallback);
clearTimeout(resizeTimer);
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", onVis); document.removeEventListener("visibilitychange", onVis);
}; };

View File

@ -35,6 +35,14 @@ export default function WhenVisible({
return () => io.disconnect(); return () => io.disconnect();
}, [rootMargin, show]); }, [rootMargin, show]);
useEffect(() => {
if (!show || !ref.current) return;
// Reveal animations for deferred sections without a global MutationObserver
ref.current.querySelectorAll(".reveal:not(.visible)").forEach((node) => {
node.classList.add("visible");
});
}, [show]);
return ( return (
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}> <div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
{show ? children : <div className="deferred-skeleton" aria-hidden />} {show ? children : <div className="deferred-skeleton" aria-hidden />}

View File

@ -35,6 +35,14 @@ FILES = [
"frontend/src/components/TripPlanner.tsx", "frontend/src/components/TripPlanner.tsx",
"frontend/src/components/WorldMap.tsx", "frontend/src/components/WorldMap.tsx",
"frontend/src/components/MovePlanClient.tsx", "frontend/src/components/MovePlanClient.tsx",
"frontend/src/components/HomeClient.tsx",
"frontend/src/components/MatcherTrigger.tsx",
"frontend/src/components/DestinationMatcher.tsx",
"frontend/src/components/ParticleCanvas.tsx",
"frontend/src/components/Loader.tsx",
"frontend/src/components/WhenVisible.tsx",
"frontend/src/app/layout.tsx",
"frontend/next.config.ts",
"frontend/src/lib/types.ts", "frontend/src/lib/types.ts",
"frontend/src/lib/planMeta.ts", "frontend/src/lib/planMeta.ts",
"frontend/src/lib/compareScore.ts", "frontend/src/lib/compareScore.ts",