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 解析失败导致的页面异常",
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
"首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
"专注时钟增加累计专注块统计",
],
},
{

View File

@ -1169,6 +1169,49 @@ img { max-width: 100%; display: block; }
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 ===== */
@media (max-width: 1024px) {
.hero-top {

View File

@ -3,18 +3,32 @@
import { useEffect, useState } from "react";
const PRESETS = [25, 45, 60];
const STORAGE_KEY = "nomadro-focus-streak";
export default function FocusTimer() {
const [minutes, setMinutes] = useState(25);
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
const [running, setRunning] = 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(() => {
if (!running) return;
if (secondsLeft <= 0) {
setRunning(false);
setDone(true);
setSessions((prev) => {
const next = prev + 1;
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* ignore */ }
return next;
});
return;
}
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
@ -55,6 +69,10 @@ export default function FocusTimer() {
</div>
</div>
<div className="focus-streak">
今日累计专注块 <b>{sessions}</b>
</div>
<div className="focus-presets">
{PRESETS.map((m) => (
<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>
</div>
{done && <p className="focus-done">🎉 完成一个专注块,起来走走吧</p>}
{done && <p className="focus-done">完成一个专注块,起来走走吧</p>}
</div>
</div>
</section>

View File

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

View File

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

View File

@ -11,7 +11,14 @@ export default function ParticleCanvas() {
const ctx = canvas.getContext("2d");
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: {
x: number; y: number; size: number;
speedX: number; speedY: number; opacity: number; hue: number;
@ -23,20 +30,27 @@ export default function ParticleCanvas() {
};
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 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.3,
speedY: (Math.random() - 0.5) * 0.3,
opacity: Math.random() * 0.5 + 0.1,
speedX: (Math.random() - 0.5) * 0.25,
speedY: (Math.random() - 0.5) * 0.25,
opacity: Math.random() * 0.4 + 0.1,
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) => {
p.x += p.speedX;
p.y += p.speedY;
@ -50,28 +64,42 @@ export default function ParticleCanvas() {
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill();
particles.slice(i + 1).forEach((p2) => {
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
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 = () => {
running = document.visibilityState === "visible";
if (running) draw();
else cancelAnimationFrame(animId);
};
resize();
create();
draw();
window.addEventListener("resize", () => { resize(); create(); });
return () => { cancelAnimationFrame(animId); };
window.addEventListener("resize", onResize);
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" />;

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>
);
}