From f30a7cf879d186978e4a41fb846ad632fb24c7eb Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 03:50:50 -0500 Subject: [PATCH] Optimize first load: defer mid-page kits, idle chrome, lighter canvas, self-hosted Outfit. Co-authored-by: Cursor --- frontend/next.config.ts | 2 + frontend/src/app/changelog/page.tsx | 10 ++ frontend/src/app/globals.css | 4 +- frontend/src/app/layout.tsx | 16 ++- .../src/components/DestinationMatcher.tsx | 9 -- frontend/src/components/HomeClient.tsx | 121 +++++++++--------- frontend/src/components/Loader.tsx | 10 +- frontend/src/components/MatcherTrigger.tsx | 11 ++ frontend/src/components/ParticleCanvas.tsx | 103 ++++++++------- frontend/src/components/WhenVisible.tsx | 8 ++ scripts/deploy_direct_sync.py | 8 ++ 11 files changed, 176 insertions(+), 126 deletions(-) create mode 100644 frontend/src/components/MatcherTrigger.tsx 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",