|
| 1 | +# i18n URL 段化架构 |
| 2 | + |
| 3 | +## 为什么是这套 |
| 4 | + |
| 5 | +社区 2026-04-16 上线的双语方案是「URL 不变 + cookie 决定语言」: |
| 6 | + |
| 7 | +```ts |
| 8 | +// 旧版 i18n/request.ts |
| 9 | +export default getRequestConfig(async () => { |
| 10 | + const cookieStore = await cookies(); |
| 11 | + const locale = cookieStore.get("locale")?.value === "en" ? "en" : "zh"; |
| 12 | + return { locale, messages }; |
| 13 | +}); |
| 14 | +``` |
| 15 | + |
| 16 | +`cookies()` 在 RSC 里调用 → next-intl 把这个 hook 注入到整棵 RSC 树 → |
| 17 | +**全站所有 page 都被钉成 dynamic**。318 篇 docs 每访问一次现 SSR 一次: |
| 18 | + |
| 19 | +- `pnpm build` 表里所有 user-facing 路由都是 `ƒ Dynamic` |
| 20 | +- `.next/prerender-manifest.json` 只剩 5 条预渲染(robots / search.json / sitemap) |
| 21 | +- Vercel Observability 显示 30 天 Fluid Active CPU 用了 3h 51m / 4h(Hobby 96%) |
| 22 | +- 流量越大 CPU 越涨,不可持续 |
| 23 | + |
| 24 | +2026-05 改成 next-intl 标准的 **URL routing** —— locale 从 URL 段(`/zh/...` / |
| 25 | +`/en/...`)推断,不读 cookie,全树可静态化。 |
| 26 | + |
| 27 | +## 文件分工 |
| 28 | + |
| 29 | +``` |
| 30 | +i18n/ |
| 31 | + routing.ts defineRouting:locales [zh, en], localePrefix: always |
| 32 | + navigation.ts createNavigation:Link / useRouter / usePathname / redirect |
| 33 | + request.ts getRequestConfig:从 requestLocale 读,不再 await cookies() |
| 34 | +
|
| 35 | +proxy.ts (Next.js 16 用 proxy.ts 不是 middleware.ts) |
| 36 | + 1. 老 leetcode 中文 slug 优先 301 |
| 37 | + 2. 其它请求交给 next-intl createMiddleware |
| 38 | +
|
| 39 | +app/ |
| 40 | + layout.tsx 极简 root layout:html/body + 全局 metadata + 全局 script |
| 41 | + (theme inline / structured data / GA / Umami) |
| 42 | + 不读 locale,不包 NextIntlClientProvider |
| 43 | + [locale]/ |
| 44 | + layout.tsx 调 setRequestLocale + 包所有 provider |
| 45 | + (NextIntlClientProvider / ThemeProvider / AuthProvider / |
| 46 | + fumadocs RootProvider) |
| 47 | + inline script 把 documentElement.lang 改到当前 locale |
| 48 | + page.tsx 首页(仍 ƒ,下一轮迁) |
| 49 | + docs/ |
| 50 | + page.tsx /[locale]/docs landing |
| 51 | + layout.tsx docs sidebar layout(用 source.getPageTree(locale)) |
| 52 | + [...slug]/ |
| 53 | + page.tsx 文章详情,force-static + setRequestLocale + 双倍预渲染 |
| 54 | + admin/ 管理后台也并入 [locale],写死 zh 也行(messages 里有) |
| 55 | + events/ feed/ login/ rank/ settings/ share/ editor/ u/ |
| 56 | +
|
| 57 | + api/ 不进 [locale],无 UI 的 fetch endpoint |
| 58 | + sitemap.ts 输出双语 + alternates.languages |
| 59 | + robots.ts disallow 用 wildcard /\*\/admin/ 等 |
| 60 | +
|
| 61 | +content/ |
| 62 | + docs/ ← 这里是 mdx 内容(旧版混在 app/docs/,已分离) |
| 63 | + fumadocs 推荐 routes / content 分离 |
| 64 | +``` |
| 65 | + |
| 66 | +## SSG 的开关 |
| 67 | + |
| 68 | +**关键开关:每个 page 和 layout 都得调 `setRequestLocale(locale)`**。少一个, |
| 69 | +对应路由就被 next-intl 退回 dynamic。 |
| 70 | + |
| 71 | +```tsx |
| 72 | +// app/[locale]/<some-route>/page.tsx |
| 73 | +import { setRequestLocale } from "next-intl/server"; |
| 74 | +import { hasLocale } from "next-intl"; |
| 75 | +import { notFound } from "next/navigation"; |
| 76 | +import { routing } from "@/i18n/routing"; |
| 77 | + |
| 78 | +export default async function Page({ |
| 79 | + params, |
| 80 | +}: { |
| 81 | + params: Promise<{ locale: string }>; |
| 82 | +}) { |
| 83 | + const { locale } = await params; |
| 84 | + if (!hasLocale(routing.locales, locale)) notFound(); |
| 85 | + setRequestLocale(locale); // ← 必须,且必须在任何 next-intl hook 之前 |
| 86 | + |
| 87 | + // ... 业务逻辑 |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +### docs 详情页额外加 `force-static` |
| 92 | + |
| 93 | +```tsx |
| 94 | +// app/[locale]/docs/[...slug]/page.tsx |
| 95 | +export const dynamic = "force-static"; |
| 96 | +``` |
| 97 | + |
| 98 | +不加这条时,即便 `setRequestLocale` 都对了,Next.js 16 仍会把这条路由 |
| 99 | +标 `ƒ Dynamic`(实测 SSG 0 页)。`force-static` 让 Next 严格按 |
| 100 | +`generateStaticParams` 预渲染所有 (locale, slug) 组合。 |
| 101 | + |
| 102 | +### generateStaticParams 双倍出货 |
| 103 | + |
| 104 | +```tsx |
| 105 | +export async function generateStaticParams() { |
| 106 | + return source.generateParams("slug", "lang").map((p) => ({ |
| 107 | + locale: p.lang as string, |
| 108 | + slug: p.slug as string[], |
| 109 | + })); |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +fumadocs 自带的 `generateParams('slug', 'lang')` 会按 i18n 配置自动产出 |
| 114 | +{locale × slug} 笛卡尔积。我们 mapping 一下 lang→locale(next-intl 用 locale)。 |
| 115 | + |
| 116 | +## 文档命名约定 |
| 117 | + |
| 118 | +`source.config.ts` 用 fumadocs **dot parser**: |
| 119 | + |
| 120 | +| 文件名 | 识别为 | |
| 121 | +| ------------------- | -------------------------------------- | |
| 122 | +| `xxx.mdx`(无后缀) | zh(默认) | |
| 123 | +| `xxx.en.mdx` | en | |
| 124 | +| `xxx.zh.mdx` | **不要用**,与无后缀冲突,build 会报错 | |
| 125 | + |
| 126 | +历史上仓库里有 8 对 conflict(无后缀 = 英文 + `.zh.mdx` = 中文翻译),改造时 |
| 127 | +统一 swap 成「无后缀 = zh + `.en.mdx` = 英文翻译」。 |
| 128 | + |
| 129 | +### 加新文章 |
| 130 | + |
| 131 | +```bash |
| 132 | +# 中文原文(默认 locale) |
| 133 | +content/docs/learn/<分区>/<新文章>.mdx |
| 134 | + |
| 135 | +# 英文翻译(可选) |
| 136 | +content/docs/learn/<分区>/<新文章>.en.mdx |
| 137 | +``` |
| 138 | + |
| 139 | +frontmatter 不需要写 `lang` 字段。fumadocs 按文件名后缀识别。 |
| 140 | + |
| 141 | +### 缺译怎么办 |
| 142 | + |
| 143 | +`lib/source.ts` 配 `fallbackLanguage: "zh"`:访问 `/en/docs/<slug>` 但 `.en.mdx` |
| 144 | +不存在时,自动渲染原文(zh)。文档站合理体验(缺译显示中文 > 显示空白)。 |
| 145 | + |
| 146 | +## 切换语言 |
| 147 | + |
| 148 | +`<LocaleToggle />` 用 next-intl 的 `useRouter().replace(pathname, { locale })`。 |
| 149 | +原理: |
| 150 | + |
| 151 | +1. `usePathname()` 返回去 locale 的 pathname(例如当前 URL 是 `/zh/docs/x`, |
| 152 | + pathname = `/docs/x`) |
| 153 | +2. `router.replace(pathname, { locale: "en" })` 自动加 `/en` 前缀 → |
| 154 | + 实际跳转 `/en/docs/x` |
| 155 | +3. next-intl 同时把 `NEXT_LOCALE` cookie 同步到 `en`,供下次访问根路径 |
| 156 | + `/` 时 middleware 选默认 locale 用 |
| 157 | + |
| 158 | +切换是真 URL 跳转,浏览器历史会留两条记录(`router.replace` 只覆盖当前记录)。 |
| 159 | + |
| 160 | +## SEO |
| 161 | + |
| 162 | +### hreflang / canonical |
| 163 | + |
| 164 | +`docs/[...slug]/page.tsx` 的 `generateMetadata` 输出: |
| 165 | + |
| 166 | +``` |
| 167 | +canonical: /<locale>/docs/<slug> |
| 168 | +languages: |
| 169 | + zh-CN: /zh/docs/<slug> |
| 170 | + en-US: /en/docs/<slug> |
| 171 | + x-default: /zh/docs/<slug> |
| 172 | +``` |
| 173 | + |
| 174 | +每个 locale 各自 canonical,避免 zh / en 互相竞争 PageRank。 |
| 175 | + |
| 176 | +### sitemap |
| 177 | + |
| 178 | +`app/sitemap.ts` 每个 URL 输出双语 entry,并填 `alternates.languages`。Google |
| 179 | +按 `xhtml:link rel="alternate" hreflang` 元素建立两个 URL 之间的关系,正确处理 |
| 180 | +搜索结果按用户语言展示。 |
| 181 | + |
| 182 | +### robots |
| 183 | + |
| 184 | +`disallow` 用 wildcard 形式 `/*/admin/` 等匹配两种 locale 前缀。 |
| 185 | + |
| 186 | +## proxy 流程 |
| 187 | + |
| 188 | +每个请求 → `proxy.ts` → |
| 189 | + |
| 190 | +1. 命中老 leetcode 路径 → 301 到拼音新 URL(带 locale 前缀) |
| 191 | +2. 否则 → next-intl `createMiddleware`: |
| 192 | + - 不带 locale 前缀 → 308 redirect 到 `/<defaultLocale>/...`(可能是 `/zh` |
| 193 | + 或按 cookie / Accept-Language) |
| 194 | + - 带 locale 前缀 → 直通 |
| 195 | + |
| 196 | +matcher 排除 `api / trpc / _next / _vercel / 任何带 . 的路径`。admin 不再排除 |
| 197 | +(已并入 `[locale]/admin`)。 |
| 198 | + |
| 199 | +## 调试常见问题 |
| 200 | + |
| 201 | +### 添加新 page 后 build 表里仍是 `ƒ Dynamic` |
| 202 | + |
| 203 | +90% 是 page 或它的 layout 缺 `setRequestLocale`。先在 page 里加,再看 layout |
| 204 | +有没有调。`setRequestLocale` 必须排在任何 next-intl hook(`useTranslations` / |
| 205 | +`getMessages` / `getTranslations`)之前。 |
| 206 | + |
| 207 | +### 加了 `setRequestLocale` 还是 `ƒ` |
| 208 | + |
| 209 | +可能 page 或 layout 调了 server fetch(例如 `await fetch(BACKEND_URL)`)。 |
| 210 | +任何 server fetch 都会让该路由 dynamic。要么改成 client fetch,要么 build 时 |
| 211 | +prebuild 到 JSON(参考 `generated/site-leaderboard.json` 模式)。 |
| 212 | + |
| 213 | +### `Both middleware file and proxy file detected` |
| 214 | + |
| 215 | +Next.js 16 只接受 `proxy.ts`,不再接受 `middleware.ts`。两个文件不能共存。 |
| 216 | + |
| 217 | +### docs sidebar 缺翻译版变体 |
| 218 | + |
| 219 | +老版用手写 `pickVariantsByLocale` 剪 sidebar tree。新版用 `source.getPageTree(locale)`,fumadocs i18n 已经按 locale 隔离,不再需要手写过滤。`SectionIndex` 组件 |
| 220 | +和 `docs/layout.tsx` 都靠这套。 |
| 221 | + |
| 222 | +### Edit on GitHub 链接 404 |
| 223 | + |
| 224 | +`DOCS_BASE` 在 `lib/github.ts` 应是 `content/docs`。如果改了 mdx 路径,要 |
| 225 | +同步改这个常量 + `lib/contributors.ts` 的 `normalizeRelativePath`。 |
| 226 | + |
| 227 | +## 已知未做(下一轮 PR) |
| 228 | + |
| 229 | +| 路由 | 当前 | 阻碍 | 处理思路 | |
| 230 | +| ------------------------ | ---- | ------------------------------------------ | ----------------------------------------- | |
| 231 | +| `/[locale]` | ƒ | `await fetchHomepageEvents()` server fetch | FloatWindow 自己 client fetch;首页变 SSG | |
| 232 | +| `/[locale]/events` | ƒ | server fetch backend | client component 化;或 ISR 化 | |
| 233 | +| `/[locale]/feed` | ƒ | server fetch | 同上 | |
| 234 | +| `/[locale]/u/[username]` | ƒ | 用户数据是 dynamic | 保持 dynamic 即可(量小) | |
| 235 | +| `/[locale]/admin/*` | ƒ | 鉴权页面,不需要 SSG | 保持 dynamic | |
| 236 | + |
| 237 | +实际**只有首页值得做**(占当前 CPU 25%)。其它要么数据动态,要么访问量小。 |
| 238 | + |
| 239 | +## 参考 |
| 240 | + |
| 241 | +- next-intl URL routing 标准 setup: <https://next-intl-docs.vercel.app/docs/routing> |
| 242 | +- fumadocs i18n: <https://fumadocs.dev/docs/headless/internationalization> |
| 243 | +- 老 cookie 方案的 commit: `d0a420d` (2026-04-16 i18n 双语系统初版) |
| 244 | +- URL 段化改造的 PR: #330 |
0 commit comments