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 = {
output: "standalone",
poweredByHeader: false,
compress: true,
async rewrites() {
return [
{

View File

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

View File

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

View File

@ -1,10 +1,19 @@
import type { Metadata } from "next";
import { Outfit } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast";
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 = {
metadataBase: new URL(SITE),
title: "nomadro · 数字游民旅居指南",
@ -28,12 +37,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<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>
<html lang="zh-CN" className={outfit.variable}>
<body>
<AuthProvider>
<ToastProvider>{children}</ToastProvider>

View File

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

View File

@ -2,14 +2,16 @@
import { useEffect, useState } from "react";
/** Brief brand splash — exits ASAP so LCP isn't blocked. */
export default function Loader() {
const [done, setDone] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => {
const hide = () => setDone(true);
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") {
const t = setTimeout(hide, delay);
@ -18,7 +20,7 @@ export default function Loader() {
const onLoad = () => setTimeout(hide, delay);
window.addEventListener("load", onLoad);
const fallback = setTimeout(hide, reduced ? 200 : 1200);
const fallback = setTimeout(hide, reduced ? 120 : 600);
return () => {
window.removeEventListener("load", onLoad);
clearTimeout(fallback);
@ -27,7 +29,7 @@ export default function Loader() {
useEffect(() => {
if (!done) return;
const t = setTimeout(() => setGone(true), 500);
const t = setTimeout(() => setGone(true), 280);
return () => clearTimeout(t);
}, [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";
/** Ambient particle field — deferred start, no O(n²) links, pauses when hidden. */
export default function ParticleCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@ -18,85 +19,95 @@ export default function ParticleCanvas() {
}
let animId = 0;
let running = true;
let running = false;
let started = false;
let resizeTimer = 0;
let particles: {
x: number; y: number; size: number;
speedX: number; speedY: number; opacity: number; hue: number;
}[] = [];
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
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 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 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
x: Math.random() * w,
y: Math.random() * window.innerHeight,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.25,
speedY: (Math.random() - 0.5) * 0.25,
opacity: Math.random() * 0.4 + 0.1,
speedX: (Math.random() - 0.5) * 0.22,
speedY: (Math.random() - 0.5) * 0.22,
opacity: Math.random() * 0.35 + 0.08,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
}));
};
const onResize = () => {
resize();
create();
};
const draw = () => {
if (!running) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const linkDist = window.innerWidth < 768 ? 0 : 90;
particles.forEach((p, i) => {
const w = window.innerWidth;
const h = window.innerHeight;
ctx.clearRect(0, 0, w, h);
for (const p of particles) {
p.x += p.speedX;
p.y += p.speedY;
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
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);
};
const onVis = () => {
const start = () => {
if (started) return;
started = true;
resize();
create();
running = document.visibilityState === "visible";
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);
};
resize();
create();
draw();
window.addEventListener("resize", onResize);
const ric = window.requestIdleCallback
? window.requestIdleCallback(() => start(), { timeout: 1800 })
: 0;
const fallback = window.setTimeout(start, 400);
window.addEventListener("resize", onResize, { passive: true });
document.addEventListener("visibilitychange", onVis);
return () => {
running = false;
cancelAnimationFrame(animId);
clearTimeout(fallback);
clearTimeout(resizeTimer);
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", onVis);
};

View File

@ -35,6 +35,14 @@ export default function WhenVisible({
return () => io.disconnect();
}, [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 (
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
{show ? children : <div className="deferred-skeleton" aria-hidden />}

View File

@ -35,6 +35,14 @@ FILES = [
"frontend/src/components/TripPlanner.tsx",
"frontend/src/components/WorldMap.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/planMeta.ts",
"frontend/src/lib/compareScore.ts",