Skip to content

Commit 2425d9a

Browse files
authored
Merge pull request #332 from InvolutionHell/perf/homepage-ssg
perf(homepage): 首页 SSG 化,剩 25% Vercel CPU 归零
2 parents aa303b1 + e61c5a0 commit 2425d9a

8 files changed

Lines changed: 1297 additions & 955 deletions

File tree

app/[locale]/page.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from "next";
12
import { setRequestLocale } from "next-intl/server";
23
import { hasLocale } from "next-intl";
34
import { notFound } from "next/navigation";
@@ -6,7 +7,6 @@ import { Hero } from "@/app/components/Hero";
67
import { DispatchNetwork } from "@/app/components/DispatchNetwork";
78
import { Footer } from "@/app/components/Footer";
89
import { FloatWindow } from "@/app/components/float-window/FloatWindow";
9-
import { fetchHomepageEvents } from "@/lib/events-fetch";
1010
import { routing } from "@/i18n/routing";
1111

1212
interface Props {
@@ -16,30 +16,59 @@ interface Props {
1616
/**
1717
* 站点首页 (/[locale])。
1818
*
19-
* i18n 改造前是 RSC + cookies(),整页 dynamic。改造后通过 setRequestLocale
20-
* 启用 SSG —— 但这页 await fetchHomepageEvents 仍然是 server fetch,会把
21-
* 整页钉成 dynamic(fetch 命中 cache 也算访问态)。
19+
* SSG 化(i18n 改造收尾,2026-05):
20+
* 原版 await fetchHomepageEvents() server fetch backend,把首页钉成
21+
* ƒ Dynamic。改造让 FloatWindow / ActivityTicker 各自 client fetch
22+
* /api/public/homepage-events,page 本身只剩纯静态渲染,build 时随
23+
* [locale] generateStaticParams 一起预渲染(zh + en 两份),Vercel
24+
* Function 调用归零。
2225
*
23-
* TODO: 把 FloatWindow 的 event prop 移到 client 自己 fetch(参考 Hero
24-
* 的 ActivityTicker 模式),让首页变 ●(SSG)。当前先保持 ƒ,等下一轮
25-
* 优化。
26+
* force-static + setRequestLocale 双保险:让 next-intl 不退回 dynamic。
2627
*/
28+
export const dynamic = "force-static";
29+
2730
export default async function HomePage({ params }: Props) {
2831
const { locale } = await params;
2932
if (!hasLocale(routing.locales, locale)) notFound();
3033
setRequestLocale(locale);
3134

32-
const homepageEvents = await fetchHomepageEvents();
33-
// FloatWindow 只展示"第一条未过期活动";fetchHomepageEvents 已把未过期排前面
34-
const latestActive = homepageEvents.find((e) => !e.deprecated) ?? null;
35-
3635
return (
3736
<>
3837
<Header />
3938
<Hero />
4039
<DispatchNetwork />
4140
<Footer />
42-
<FloatWindow event={latestActive} />
41+
<FloatWindow />
4342
</>
4443
);
4544
}
45+
46+
/**
47+
* 首页 metadata:覆盖 root layout 的 alternates。
48+
*
49+
* canonical 指向当前 locale 的首页(/zh 或 /en),让两个 locale 各自有独立
50+
* 的 canonical URL,避免 Google 把它们当成重复内容互相争 PageRank。
51+
*
52+
* languages(hreflang)三向声明,告诉 Google 同一首页的另一语言版本在哪。
53+
*/
54+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
55+
const { locale } = await params;
56+
if (!hasLocale(routing.locales, locale)) notFound();
57+
setRequestLocale(locale);
58+
59+
return {
60+
alternates: {
61+
canonical: `/${locale}`,
62+
languages: {
63+
"zh-CN": "/zh",
64+
"en-US": "/en",
65+
"x-default": "/zh",
66+
},
67+
},
68+
openGraph: {
69+
url: `/${locale}`,
70+
locale: locale === "en" ? "en_US" : "zh_CN",
71+
alternateLocale: locale === "en" ? ["zh_CN"] : ["en_US"],
72+
},
73+
};
74+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextResponse } from "next/server";
2+
import { fetchHomepageEvents } from "@/lib/events-fetch";
3+
4+
/**
5+
* 首页活动数据的客户端代理 API。
6+
*
7+
* 为什么需要这个端点(i18n 改造收尾,2026-05):
8+
* 首页 page.tsx 之前用 server component await fetchHomepageEvents(),把
9+
* 首页钉成 ƒ Dynamic(任何 server fetch 都阻挡 SSG)。改造让 FloatWindow /
10+
* ActivityTicker 改 client component 自己 fetch,但他们直接打后端会涉及
11+
* CORS + NEXT_PUBLIC_BACKEND_URL 暴露。走自家 API 中转更干净:
12+
* - 浏览器调 /api/public/homepage-events(同源,无 CORS)
13+
* - 路由 server-side 调后端 /api/events(BACKEND_URL 不暴露)
14+
* - revalidate: 300 让 Next.js Data Cache 命中 5 分钟内的请求,最多
15+
* 每 5min 一次 server fetch,Vercel CPU 几乎为零
16+
*
17+
* 即使首页 SSG,每个浏览器加载会发一次这条请求,但因为 fetch cache + ISR
18+
* 命中(HTTP 304),Vercel Function 大部分时候不会冷启动。
19+
*/
20+
21+
// ISR:5 分钟缓存(与 lib/events-fetch.ts 内部 revalidate 对齐)
22+
export const revalidate = 300;
23+
24+
export async function GET() {
25+
const events = await fetchHomepageEvents();
26+
return NextResponse.json(events, {
27+
headers: {
28+
// 浏览器侧也缓存 5 分钟,刷新页面期间不再打 origin
29+
"cache-control": "public, max-age=300, s-maxage=300",
30+
},
31+
});
32+
}

app/api/public/top-docs/route.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from "next/server";
2+
3+
interface TopDocDto {
4+
path: string;
5+
title: string;
6+
views: number;
7+
}
8+
9+
/**
10+
* 首页 "本周最热" 数据的客户端代理 API。
11+
*
12+
* 与 /api/public/homepage-events 同一思路(i18n 改造收尾,2026-05):
13+
* 原来的 HotDocsPreview server component 直接 await fetchTopDocs,server
14+
* fetch 让 Hero 段所在的整个首页 RSC tree 变 ƒ Dynamic。改为 client fetch
15+
* 走自家代理:
16+
* - 浏览器调 /api/public/top-docs(同源,无 CORS)
17+
* - 路由 server-side 调后端 /analytics/top-docs(BACKEND_URL 不暴露)
18+
* - revalidate=300 让 Next.js Data Cache 命中,最多每 5min 一次后端调用
19+
* - 浏览器侧 cache-control 5min 复用响应
20+
*
21+
* 后端拒服务时静默返回空数组,HotDocsPreview 显示"empty"。
22+
*/
23+
export const revalidate = 300;
24+
25+
export async function GET() {
26+
const backendUrl = process.env.BACKEND_URL;
27+
if (!backendUrl) {
28+
return NextResponse.json([], {
29+
headers: { "cache-control": "public, max-age=300, s-maxage=300" },
30+
});
31+
}
32+
33+
try {
34+
const res = await fetch(
35+
`${backendUrl}/analytics/top-docs?window=7d&limit=5`,
36+
{
37+
next: { revalidate: 300 },
38+
// UA 带 InvolutionHell-SSR token,让 Cloudflare Bot Fight Mode 放行
39+
// (CF Custom Rule 用这个 token 识别"自己人")。
40+
headers: {
41+
accept: "application/json",
42+
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
43+
},
44+
},
45+
);
46+
if (!res.ok) {
47+
return NextResponse.json([], {
48+
headers: { "cache-control": "public, max-age=60" },
49+
});
50+
}
51+
const json = await res.json();
52+
const data: TopDocDto[] = Array.isArray(json?.data) ? json.data : [];
53+
return NextResponse.json(data, {
54+
headers: { "cache-control": "public, max-age=300, s-maxage=300" },
55+
});
56+
} catch {
57+
return NextResponse.json([], {
58+
headers: { "cache-control": "public, max-age=60" },
59+
});
60+
}
61+
}

app/components/ActivityTicker.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
14
import Link from "next/link";
25
import { cn } from "@/lib/utils";
3-
import { fetchHomepageEvents, type HomepageEvent } from "@/lib/events-fetch";
6+
import type { HomepageEvent } from "@/lib/events-fetch";
47

58
/**
69
* 首页顶部活动轮播。
710
*
811
* 数据源:后端 /api/events(管理员在 /admin/events 维护)。
9-
* 后端失败时返回空数组,组件 return null 不渲染轮播(整条不显示)。
1012
*
11-
* 为什么是 Server Component:
12-
* - 没有交互状态(纯 CSS 跑马灯动画)
13-
* - SSR 时已经能拿到数据,避免 client fetch 造成首屏闪烁
14-
* - revalidate: 300 让 Neon 压力稳定在每 5min 一次 SSR
13+
* 为什么是 Client Component(i18n 改造收尾,2026-05):
14+
* 原来是 async server component,server fetch 让首页 page.tsx 整页变 ƒ
15+
* Dynamic(任何 server fetch 都阻挡 SSG)。改 client + 自家 ISR 代理后:
16+
* - 首页 page.tsx 可以纯静态预渲染,Vercel CPU 归零
17+
* - 数据走 /api/public/homepage-events(revalidate=300,5min 缓存)
18+
* - 浏览器拿到 304 命中本地缓存,不打 Vercel Function
19+
* - 首屏没有 ticker(events 还在 fetch),hydrate 后出现,不影响 LCP
20+
* (ticker 不是 LCP 元素)
1521
*/
1622

1723
const MAX_ITEMS = 3;
@@ -21,9 +27,23 @@ type ActivityTickerProps = {
2127
className?: string;
2228
};
2329

24-
export async function ActivityTicker({ className }: ActivityTickerProps) {
25-
const all = await fetchHomepageEvents();
26-
const events = all.slice(0, MAX_ITEMS);
30+
export function ActivityTicker({ className }: ActivityTickerProps) {
31+
const [events, setEvents] = useState<HomepageEvent[]>([]);
32+
33+
useEffect(() => {
34+
let cancelled = false;
35+
fetch("/api/public/homepage-events", { cache: "force-cache" })
36+
.then((r) => (r.ok ? r.json() : []))
37+
.then((data: HomepageEvent[]) => {
38+
if (!cancelled) setEvents(data.slice(0, MAX_ITEMS));
39+
})
40+
.catch(() => {
41+
// 静默失败:ticker 不显示比报错更友好
42+
});
43+
return () => {
44+
cancelled = true;
45+
};
46+
}, []);
2747

2848
if (events.length === 0) return null;
2949

app/components/HotDocsPreview.tsx

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,18 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
14
import Link from "next/link";
2-
import { getTranslations } from "next-intl/server";
5+
import { useTranslations } from "next-intl";
36

47
interface TopDocDto {
58
path: string;
69
title: string;
710
views: number;
811
}
912

10-
async function fetchTopDocs(): Promise<TopDocDto[]> {
11-
const backendUrl = process.env.BACKEND_URL;
12-
if (!backendUrl) {
13-
if (process.env.NODE_ENV !== "production") {
14-
console.warn(
15-
"[HotDocsPreview] BACKEND_URL 未配置,跳过 Top Docs 请求。本地请在 .env.local 设置。",
16-
);
17-
}
18-
return [];
19-
}
20-
try {
21-
const res = await fetch(
22-
`${backendUrl}/analytics/top-docs?window=7d&limit=5`,
23-
{
24-
next: { revalidate: 300 },
25-
// UA 必须带 "InvolutionHell-SSR" token,否则 Cloudflare Bot Fight Mode
26-
// 会按 Vercel runner IP 信誉判定为 bot 拦截(CF Custom Rule 用这个
27-
// token 识别"自己人"放行)。其他 SSR fetcher(fetchProfile / events /
28-
// feed)都已经带,唯独本组件之前漏了导致首页 "本周最热" 一直空。
29-
headers: {
30-
accept: "application/json",
31-
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
32-
},
33-
},
34-
);
35-
if (!res.ok) return [];
36-
const json = await res.json();
37-
return Array.isArray(json?.data) ? json.data : [];
38-
} catch {
39-
return [];
40-
}
41-
}
42-
4313
/**
4414
* HotDocsPreview 的骨架屏。
45-
* 在首页被 <Suspense> 包裹时作为 fallback,让页面 shell 先 stream 给浏览器,
46-
* 等后端 /analytics/top-docs 返回后再替换成真实内容。
47-
* 结构刻意贴合真组件(同样的 5 行 + 标题栏),避免 CLS。
15+
* 数据加载期间显示,避免 CLS(结构刻意贴合真组件,5 行 + 标题栏)。
4816
*/
4917
export function HotDocsPreviewSkeleton() {
5018
return (
@@ -76,9 +44,39 @@ export function HotDocsPreviewSkeleton() {
7644
);
7745
}
7846

79-
export async function HotDocsPreview() {
80-
const docs = await fetchTopDocs();
81-
const t = await getTranslations("hotDocs");
47+
/**
48+
* HotDocsPreview - 首页 "本周最热" 文档榜。
49+
*
50+
* 客户端化(i18n 改造收尾,2026-05):
51+
* 原来是 async server component(await fetchTopDocs + getTranslations),
52+
* server fetch 让首页 RSC tree 整体 ƒ Dynamic。改 client 后:
53+
* - 数据走 /api/public/top-docs(revalidate=300 ISR + 浏览器 5min 缓存)
54+
* - 翻译用 next-intl 的 useTranslations(client hook)
55+
* - 首屏先显示 Skeleton,hydrate 后 fetch + 替换为真实内容(不影响 LCP)
56+
*/
57+
export function HotDocsPreview() {
58+
const t = useTranslations("hotDocs");
59+
const [docs, setDocs] = useState<TopDocDto[] | null>(null);
60+
61+
useEffect(() => {
62+
let cancelled = false;
63+
fetch("/api/public/top-docs", { cache: "force-cache" })
64+
.then((r) => (r.ok ? r.json() : []))
65+
.then((data: TopDocDto[]) => {
66+
if (!cancelled) setDocs(data);
67+
})
68+
.catch(() => {
69+
if (!cancelled) setDocs([]);
70+
});
71+
return () => {
72+
cancelled = true;
73+
};
74+
}, []);
75+
76+
// fetch 未完成:渲染 Skeleton(与 Suspense fallback 同形态)
77+
if (docs === null) {
78+
return <HotDocsPreviewSkeleton />;
79+
}
8280

8381
return (
8482
<div className="border border-[var(--foreground)] p-6 bg-[var(--background)]">

app/components/float-window/FloatWindow.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useCallback } from "react";
3+
import { useState, useCallback, useEffect } from "react";
44
import { usePathname } from "next/navigation";
55
import { motion, AnimatePresence } from "motion/react";
66
import type { HomepageEvent } from "@/lib/events-fetch";
@@ -10,26 +10,43 @@ import styles from "./FloatWindow.module.css";
1010

1111
/**
1212
* FloatWindow - 复古报纸风格的活动预告悬浮窗。
13-
* 仅显示最新的一条活动,仅在首页 (/) 可见。
13+
* 仅显示最新的一条活动,仅在首页(locale 段下的 /) 可见。
1414
*
15-
* 数据来源:
16-
* - 之前从 data/event.json 直接 import
17-
* - 现在由上游 Server Component(app/page.tsx)调 lib/events-fetch.ts 拉 /api/events
18-
* 后通过 event prop 传进来;后端失败时 fetch 内部会 fallback 到 JSON
15+
* 数据来源(i18n 改造收尾,2026-05):
16+
* - 之前由上游 Server Component(app/page.tsx)调 fetchHomepageEvents
17+
* 后通过 event prop 传入;这让首页变 ƒ Dynamic
18+
* - 改为本组件自己 client fetch /api/public/homepage-events,首页可以
19+
* SSG 静态化。组件仍然按"第一条未过期活动"的逻辑挑一条展示。
1920
*/
2021

21-
interface FloatWindowProps {
22-
/** 要展示的单条活动;null 或已过期时组件不渲染 */
23-
event: HomepageEvent | null;
24-
}
25-
26-
export function FloatWindow({ event }: FloatWindowProps) {
22+
export function FloatWindow() {
2723
const pathname = usePathname();
2824
const [isCollapsed, setIsCollapsed] = useState(false);
2925
const [isDismissed, setIsDismissed] = useState(false);
26+
const [event, setEvent] = useState<HomepageEvent | null>(null);
27+
28+
// 仅在首页(/zh、/en 或裸 /)可见。i18n 段化后 pathname 是 /<locale>。
29+
const isHomePage =
30+
pathname === "/" || pathname === "/zh" || pathname === "/en";
3031

31-
// 仅在首页 (/) 可见
32-
const isHomePage = pathname === "/";
32+
useEffect(() => {
33+
if (!isHomePage) return;
34+
let cancelled = false;
35+
fetch("/api/public/homepage-events", { cache: "force-cache" })
36+
.then((r) => (r.ok ? r.json() : []))
37+
.then((data: HomepageEvent[]) => {
38+
if (cancelled) return;
39+
// 未过期的活动排前面(API 已排序),挑第一条非 deprecated
40+
const latest = data.find((e) => !e.deprecated) ?? null;
41+
setEvent(latest);
42+
})
43+
.catch(() => {
44+
// 静默失败:组件不显示比报错更友好
45+
});
46+
return () => {
47+
cancelled = true;
48+
};
49+
}, [isHomePage]);
3350

3451
const handleDismiss = useCallback(() => setIsDismissed(true), []);
3552
const handleToggle = useCallback(() => setIsCollapsed((prev) => !prev), []);

0 commit comments

Comments
 (0)