从 0 到 1 搭建一个支持 9 种语言、部署在 Cloudflare Workers 上的游戏攻略网站,核心思路:用文件结构替代中间件,把 Worker 成本砍到最低。
一、项目背景
最近做了一个游戏的攻略站点,需要:
- 支持 9 种语言(英/日/中/西/法/德/韩/意/葡)
- SEO 友好(hreflang、canonical、sitemap、JSON-LD 全套)
- 部署在 Cloudflare Workers 免费套餐(10 万请求/天)
- 尽可能降低 Worker 调用次数
二、技术栈
| 层 | 选型 |
|---|---|
| 框架 | Next.js 15 (App Router, RSC) |
| 国际化 | next-intl v4 |
| 样式 | Tailwind CSS |
| 部署 | Cloudflare Workers (OpenNext) |
| 分析 | Google Analytics 4 |
三、核心架构:零中间件多语言
1. 问题:中间件的代价
传统的 next-intl 方案用中间件检测语言然后重定向。但在 Cloudflare Workers 上,每次重定向 = 2 次 Worker 调用(初始请求 + 重定向目标)。免费套餐每天只有 10 万次,重定向直接吃掉一半配额。
2. 方案:用路由组(Route Group)替代中间件
删掉 middleware.ts,用 Next.js App Router 的路由组天然隔离语言:
bash
src/app/
├── layout.tsx ← 根布局:HTML 骨架 + GA + JSON-LD
├── (root)/ ← 英文路由组(URL 无前缀)
│ ├── layout.tsx ← LocaleShell(defaultMessages=en)
│ ├── page.tsx → /
│ ├── about/page.tsx → /about
│ └── gameplay/page.tsx → /gameplay
└── [locale]/ ← 多语言路由组(URL 有前缀)
├── layout.tsx ← LocaleShell(defaultMessages=对应语言)
├── page.tsx → /zh, /ja, /es ...
├── about/page.tsx → /zh/about
└── gameplay/page.tsx → /zh/gameplay
关键点:
(root)是 Next.js 的路由组语法,不会出现在 URL 中 。(root)/about/page.tsx对应/about,不是/(root)/about。- 两个路由组互斥------一个 URL 要么匹配
(root)(英文),要么匹配[locale](其他语言),永远不会同时匹配。 - 英文没有前缀(
/about),其他语言带前缀(/zh/about),干净直观。
3. 为什么不用 localePrefix: 'always'?
如果所有语言都带前缀(/en/about、/zh/about),可以用中间件统一处理。但英文作为默认语言不带前缀是 SEO 最佳实践------Google 推荐 x-default 指向无前缀版本。
四、两个 LocaleShell 的设计
每个页面都有两个 page.tsx ------一个在 (root)/,一个在 [locale]/。它们共享同一个 View 组件,只是获取语言的方式不同:
javascript
// (root)/about/page.tsx --- 英文,硬编码
export function generateMetadata() {
return getMetadata('en');
}
export default function AboutPage() {
setRequestLocale('en');
return <AboutView />;
}
// [locale]/about/page.tsx --- 多语言,从 URL 读取
export async function generateMetadata({ params }) {
const { locale } = await params;
return getMetadata(locale);
}
export default async function AboutPage({ params }) {
const { locale } = await params;
setRequestLocale(locale);
return <AboutView />;
}
代价 :9 个页面 × 2 = 18 个 page.tsx 文件。但换来的是零中间件、零重定向、每次请求只消耗 1 次 Worker 调用。
五、SEO 全套配置
1. hreflang 标签
在 src/lib/seo.ts 中统一生成,每个页面的 generateMetadata() 返回值自动包含 alternates,Next.js 会在 <head> 中渲染对应的 <link rel="alternate"> 标签。
2. JSON-LD 结构化数据
- 全站:
WebSite+VideoGame(根布局) - 每个页面:
BreadcrumbList - /faq:
FAQPage - /release-date:
Article
3. Sitemap
生成 9 语言 × N 页面 的完整 sitemap,每个 URL 包含 priority 和 alternates。
六、缓存策略
arduino
Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800
s-maxage=86400:Cloudflare 边缘缓存 HTML 24 小时stale-while-revalidate=604800:过期后 7 天内先返回旧内容,后台重新验证- 不设置 max-age:浏览器不缓存,确保语言切换即时生效
静态资源(_next/static/*、SVG、robots.txt)通过 wrangler.jsonc 的 assets binding 直接由 Cloudflare Static Assets 提供,完全不经过 Worker,零成本。
七、部署
使用 @opennextjs/cloudflare 把 Next.js 打包成 Cloudflare Worker:
arduino
npm run dev # 本地开发
npm run deploy # 部署到 Cloudflare Workers
wrangler.jsonc 关键配置:
main:.open-next/worker.jsassets.directory:.open-next/assets(免费,不经过 Worker)compatibility_date:保持运行时更新
八、踩坑记录
1. getMessages() 返回错误语言
在 [locale]/layout.tsx 中使用 getMessages() 时,即使调用了 setRequestLocale('zh'),返回的仍然是英文消息。
解决:改用显式动态导入:
javascript
const messages = (await import(`@/messages/${locale}.json`)).default;
2. 不要在根布局调用 setRequestLocale
根布局中的 setRequestLocale('en') 会污染上下文,导致 [locale] 子路由也收到 'en'。
解决 :只在 (root)/ 和 [locale]/ 的 layout 中分别设置。
3. max-age vs s-maxage
如果用 max-age,浏览器会缓存 HTML,导致用户切换语言后看到的还是旧语言的页面。
解决 :只用 s-maxage,让 CDN 缓存,浏览器每次都请求 CDN。
九、总结
| 传统方案 | 本方案 | |
|---|---|---|
| 语言检测 | 中间件检测 → 重定向 | 路由组天然隔离 |
| Worker 调用 | 每次可能 2 次 | 永远 1 次 |
| 状态依赖 | 需要 cookie / Accept-Language | 纯 URL 决定语言 |
| 文件结构 | 单套 page.tsx + 动态路由 | 双套 page.tsx(文件多但逻辑简单) |
核心取舍:用文件数量换 Worker 成本和架构简洁性。对于内容站点(页面数量有限、不经常变动),这个取舍非常值得。
项目链接:The Duskbloods --- Release Date, Gameplay & Network Test Guide | Duskbloods Guide
技术栈:Next.js 15 · next-intl · Tailwind CSS · Cloudflare Workers