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:
parent
9a6474a69a
commit
f30a7cf879
@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -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: "计划深化",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`;
|
||||
nodes.forEach((el, i) => {
|
||||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}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);
|
||||
};
|
||||
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,12 +241,18 @@ export default function HomeClient(props: Props) {
|
||||
<BackToTop />
|
||||
<SectionNav />
|
||||
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
||||
{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">
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
11
frontend/src/components/MatcherTrigger.tsx
Normal file
11
frontend/src/components/MatcherTrigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user