Optimize homepage: defer below-fold mounts, lighter particles, faster loader.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 02:22:55 -05:00
parent 90abeac6f6
commit 8a0ca181e7
7 changed files with 212 additions and 64 deletions

View File

@ -30,6 +30,8 @@ const LOGS = [
"新增饮食饮水注意事项", "新增饮食饮水注意事项",
"修复生产环境 CSS 解析失败导致的页面异常", "修复生产环境 CSS 解析失败导致的页面异常",
"部署改为原生 systemd(非 Docker 优先),增量构建更快", "部署改为原生 systemd(非 Docker 优先),增量构建更快",
"首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
"专注时钟增加累计专注块统计",
], ],
}, },
{ {

View File

@ -1169,6 +1169,49 @@ img { max-width: 100%; display: block; }
transform: translateY(0); transform: translateY(0);
} }
.section {
content-visibility: auto;
contain-intrinsic-size: auto 480px;
}
.deferred-skeleton {
height: 120px;
margin: 24px auto;
max-width: 1200px;
border-radius: var(--radius-xl);
background: linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0.07), rgba(255,255,255,0.03));
background-size: 200% 100%;
animation: deferredShimmer 1.2s ease-in-out infinite;
}
@keyframes deferredShimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.focus-streak {
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
margin: -4px 0 12px;
}
.focus-streak b {
color: var(--accent-3);
font-size: 1.1rem;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
.deferred-skeleton { animation: none; }
.loader-ring,
.loader-progress { animation: none !important; }
}
/* ===== Responsive ===== */ /* ===== Responsive ===== */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.hero-top { .hero-top {

View File

@ -3,18 +3,32 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const PRESETS = [25, 45, 60]; const PRESETS = [25, 45, 60];
const STORAGE_KEY = "nomadro-focus-streak";
export default function FocusTimer() { export default function FocusTimer() {
const [minutes, setMinutes] = useState(25); const [minutes, setMinutes] = useState(25);
const [secondsLeft, setSecondsLeft] = useState(25 * 60); const [secondsLeft, setSecondsLeft] = useState(25 * 60);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [sessions, setSessions] = useState(0);
useEffect(() => {
try {
const n = Number(localStorage.getItem(STORAGE_KEY) || "0");
if (!Number.isNaN(n)) setSessions(n);
} catch { /* ignore */ }
}, []);
useEffect(() => { useEffect(() => {
if (!running) return; if (!running) return;
if (secondsLeft <= 0) { if (secondsLeft <= 0) {
setRunning(false); setRunning(false);
setDone(true); setDone(true);
setSessions((prev) => {
const next = prev + 1;
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* ignore */ }
return next;
});
return; return;
} }
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
@ -55,6 +69,10 @@ export default function FocusTimer() {
</div> </div>
</div> </div>
<div className="focus-streak">
今日累计专注块 <b>{sessions}</b>
</div>
<div className="focus-presets"> <div className="focus-presets">
{PRESETS.map((m) => ( {PRESETS.map((m) => (
<button key={m} className={`filter-btn${minutes === m && !running ? " active" : ""}`} onClick={() => pick(m)} disabled={running}> <button key={m} className={`filter-btn${minutes === m && !running ? " active" : ""}`} onClick={() => pick(m)} disabled={running}>
@ -73,7 +91,7 @@ export default function FocusTimer() {
)} )}
<button className="btn btn-ghost" onClick={reset}>重置</button> <button className="btn btn-ghost" onClick={reset}>重置</button>
</div> </div>
{done && <p className="focus-done">🎉 完成一个专注块,起来走走吧</p>} {done && <p className="focus-done">完成一个专注块,起来走走吧</p>}
</div> </div>
</div> </div>
</section> </section>

View File

@ -28,6 +28,7 @@ import BackToTop from "./BackToTop";
import TripPlanner from "./TripPlanner"; import TripPlanner from "./TripPlanner";
import Calculator from "./Calculator"; import Calculator from "./Calculator";
import SpinGlobe from "./SpinGlobe"; import SpinGlobe from "./SpinGlobe";
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 });
@ -167,49 +168,49 @@ export default function HomeClient(props: Props) {
<SpinGlobe destinations={props.destinations} /> <SpinGlobe destinations={props.destinations} />
<DailyQuote /> <DailyQuote />
<ToolsStrip /> <ToolsStrip />
<TimezoneBoard destinations={props.destinations} /> <WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
<JetLagEstimator destinations={props.destinations} /> <WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
<TripPlanner destinations={props.destinations} /> <TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} /> <Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} /> <WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
<SavingsGoal destinations={props.destinations} /> <WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
<RunwayCalculator destinations={props.destinations} /> <WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
<FirstMonthCost destinations={props.destinations} /> <WhenVisible><FirstMonthCost destinations={props.destinations} /></WhenVisible>
<ExpenseTracker /> <WhenVisible><ExpenseTracker /></WhenVisible>
<TaxDayCounter /> <WhenVisible><TaxDayCounter /></WhenVisible>
<FlightCost destinations={props.destinations} /> <WhenVisible><FlightCost destinations={props.destinations} /></WhenVisible>
<CurrencyConverter /> <WhenVisible><CurrencyConverter /></WhenVisible>
<MeetingWindow destinations={props.destinations} /> <WhenVisible><MeetingWindow destinations={props.destinations} /></WhenVisible>
<DaylightWorkHours destinations={props.destinations} /> <WhenVisible><DaylightWorkHours destinations={props.destinations} /></WhenVisible>
<WifiWorkScore destinations={props.destinations} /> <WhenVisible><WifiWorkScore destinations={props.destinations} /></WhenVisible>
<WorkSpotCost destinations={props.destinations} /> <WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
<DataBudgetPlanner /> <WhenVisible><DataBudgetPlanner /></WhenVisible>
<FocusTimer /> <WhenVisible><FocusTimer /></WhenVisible>
<NomadScore /> <WhenVisible><NomadScore /></WhenVisible>
<Lifestyle /> <WhenVisible><Lifestyle /></WhenVisible>
<Charts {...props.charts} /> <WhenVisible><Charts {...props.charts} /></WhenVisible>
<VisaSection visas={props.visas} /> <WhenVisible><VisaSection visas={props.visas} /></WhenVisible>
<VisaStayCountdown destinations={props.destinations} /> <WhenVisible><VisaStayCountdown destinations={props.destinations} /></WhenVisible>
<InsuranceGuide /> <WhenVisible><InsuranceGuide /></WhenVisible>
<EmergencyContacts destinations={props.destinations} /> <WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
<AtmCashGuide destinations={props.destinations} /> <WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
<FoodWaterTips destinations={props.destinations} /> <WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
<CoworkingGuide /> <WhenVisible><CoworkingGuide /></WhenVisible>
<HousingGuide destinations={props.destinations} /> <WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
<SimGuide destinations={props.destinations} /> <WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
<PowerPlugGuide destinations={props.destinations} /> <WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
<CafeGuide destinations={props.destinations} /> <WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
<SeasonGuide destinations={props.destinations} /> <WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
<PackingChecklist /> <WhenVisible><PackingChecklist /></WhenVisible>
<BagWeightEstimator /> <WhenVisible><BagWeightEstimator /></WhenVisible>
<ArrivalChecklist destinations={props.destinations} /> <WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
<Phrasebook destinations={props.destinations} /> <WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
<CityNotes destinations={props.destinations} /> <WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
<NomadEvents /> <WhenVisible><NomadEvents /></WhenVisible>
<Tools tools={props.tools} /> <WhenVisible><Tools tools={props.tools} /></WhenVisible>
<BlogSection posts={props.blog} /> <WhenVisible><BlogSection posts={props.blog} /></WhenVisible>
<FAQSection faqs={props.faqs} /> <WhenVisible><FAQSection faqs={props.faqs} /></WhenVisible>
<Community testimonials={props.testimonials} /> <WhenVisible><Community testimonials={props.testimonials} /></WhenVisible>
<Footer /> <Footer />
<BackToTop /> <BackToTop />
<SectionNav /> <SectionNav />

View File

@ -4,22 +4,35 @@ import { useEffect, useState } from "react";
export default function Loader() { export default function Loader() {
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => { useEffect(() => {
const hide = () => setDone(true); const hide = () => setDone(true);
const onLoad = () => setTimeout(hide, 900); const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const delay = reduced ? 0 : 380;
if (document.readyState === "complete") { if (document.readyState === "complete") {
const t = setTimeout(hide, 900); const t = setTimeout(hide, delay);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
const onLoad = () => setTimeout(hide, delay);
window.addEventListener("load", onLoad); window.addEventListener("load", onLoad);
const fallback = setTimeout(hide, 2200); const fallback = setTimeout(hide, reduced ? 200 : 1200);
return () => { return () => {
window.removeEventListener("load", onLoad); window.removeEventListener("load", onLoad);
clearTimeout(fallback); clearTimeout(fallback);
}; };
}, []); }, []);
useEffect(() => {
if (!done) return;
const t = setTimeout(() => setGone(true), 500);
return () => clearTimeout(t);
}, [done]);
if (gone) return null;
return ( return (
<div className={`loader${done ? " loader-done" : ""}`} id="loader" aria-hidden={done}> <div className={`loader${done ? " loader-done" : ""}`} id="loader" aria-hidden={done}>
<div className="loader-inner"> <div className="loader-inner">

View File

@ -11,7 +11,14 @@ export default function ParticleCanvas() {
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
let animId: number; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
canvas.style.display = "none";
return;
}
let animId = 0;
let running = true;
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;
@ -23,20 +30,27 @@ export default function ParticleCanvas() {
}; };
const create = () => { const create = () => {
const count = Math.min(Math.floor(window.innerWidth / 12), 120); const count = Math.min(Math.floor(window.innerWidth / 28), 48);
particles = Array.from({ length: count }, () => ({ particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width, x: Math.random() * canvas.width,
y: Math.random() * canvas.height, y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5, size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.3, speedX: (Math.random() - 0.5) * 0.25,
speedY: (Math.random() - 0.5) * 0.3, speedY: (Math.random() - 0.5) * 0.25,
opacity: Math.random() * 0.5 + 0.1, opacity: Math.random() * 0.4 + 0.1,
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;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
const linkDist = window.innerWidth < 768 ? 0 : 90;
particles.forEach((p, i) => { particles.forEach((p, i) => {
p.x += p.speedX; p.x += p.speedX;
p.y += p.speedY; p.y += p.speedY;
@ -50,28 +64,42 @@ export default function ParticleCanvas() {
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`; ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill(); ctx.fill();
particles.slice(i + 1).forEach((p2) => { if (linkDist > 0) {
for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j];
const dx = p.x - p2.x; const dx = p.x - p2.x;
const dy = p.y - p2.y; const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.hypot(dx, dy);
if (dist < 120) { if (dist < linkDist) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(p.x, p.y); ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y); ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`; ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.stroke(); ctx.stroke();
} }
}); }
}
}); });
animId = requestAnimationFrame(draw); animId = requestAnimationFrame(draw);
}; };
const onVis = () => {
running = document.visibilityState === "visible";
if (running) draw();
else cancelAnimationFrame(animId);
};
resize(); resize();
create(); create();
draw(); draw();
window.addEventListener("resize", () => { resize(); create(); }); window.addEventListener("resize", onResize);
return () => { cancelAnimationFrame(animId); }; document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", onVis);
};
}, []); }, []);
return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />; return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />;

View File

@ -0,0 +1,43 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/** Mount children only when near the viewport — defers heavy below-fold chunks. */
export default function WhenVisible({
children,
rootMargin = "640px 0px",
minHeight = 160,
}: {
children: ReactNode;
rootMargin?: string;
minHeight?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || show) return;
if (typeof IntersectionObserver === "undefined") {
setShow(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShow(true);
io.disconnect();
}
},
{ rootMargin, threshold: 0.01 }
);
io.observe(el);
return () => io.disconnect();
}, [rootMargin, show]);
return (
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
{show ? children : <div className="deferred-skeleton" aria-hidden />}
</div>
);
}