diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index d6a6da0..3830065 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ poweredByHeader: false,
+ compress: true,
async rewrites() {
return [
{
diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index 6c514b1..9e92a96 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -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: "计划深化",
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index bf40383..b5276e5 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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);
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 203fc88..1a7cdf6 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -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 (
-
-
-
{children}
diff --git a/frontend/src/components/DestinationMatcher.tsx b/frontend/src/components/DestinationMatcher.tsx
index 2138ded..bbb4996 100644
--- a/frontend/src/components/DestinationMatcher.tsx
+++ b/frontend/src/components/DestinationMatcher.tsx
@@ -267,12 +267,3 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
);
}
-
-export function MatcherTrigger({ onClick }: { onClick: () => void }) {
- return (
-
- );
-}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index f9b1408..9d11247 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -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 = (loader: () => Promise<{ default: ComponentType
}>) =>
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(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) {
setMatcherOpen(true)} />
-
-
-
+
+
+
-
-
+
+
@@ -244,26 +241,32 @@ export default function HomeClient(props: Props) {
setMatcherOpen(true)} />
- setMatcherOpen(false)} />
- setMatcherOpen(true)} />
-
-
-
-
+ {matcherOpen && (
+ setMatcherOpen(false)} />
+ )}
+ {chromeReady && (
+ <>
+ setMatcherOpen(true)} />
+
+
+
+
+ >
+ )}
{compareList.length > 0 && (
⚖️ 已选 {compareList.length}/4
-
- = 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(); }}
- >
- 完整对比页
-
-
+
+ = 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(); }}
+ >
+ 完整对比页
+
+
)}
diff --git a/frontend/src/components/Loader.tsx b/frontend/src/components/Loader.tsx
index 0da8ec9..3588c06 100644
--- a/frontend/src/components/Loader.tsx
+++ b/frontend/src/components/Loader.tsx
@@ -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]);
diff --git a/frontend/src/components/MatcherTrigger.tsx b/frontend/src/components/MatcherTrigger.tsx
new file mode 100644
index 0000000..dbbaf83
--- /dev/null
+++ b/frontend/src/components/MatcherTrigger.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+/** Lightweight FAB — kept separate so DestinationMatcher can be code-split. */
+export default function MatcherTrigger({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ParticleCanvas.tsx b/frontend/src/components/ParticleCanvas.tsx
index a516cf4..9348de5 100644
--- a/frontend/src/components/ParticleCanvas.tsx
+++ b/frontend/src/components/ParticleCanvas.tsx
@@ -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(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);
};
diff --git a/frontend/src/components/WhenVisible.tsx b/frontend/src/components/WhenVisible.tsx
index b0c97c2..2759b98 100644
--- a/frontend/src/components/WhenVisible.tsx
+++ b/frontend/src/components/WhenVisible.tsx
@@ -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 (
{show ? children :
}
diff --git a/scripts/deploy_direct_sync.py b/scripts/deploy_direct_sync.py
index 890c422..10fe27b 100644
--- a/scripts/deploy_direct_sync.py
+++ b/scripts/deploy_direct_sync.py
@@ -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",