在线体验:zhimayuandi.com (导航右上角「中 / EN」一键切换,可以把整个站切成英文) 一个纯静态的儿童启蒙站点——56 个前端工具、26 个互动教程、15 个 Phaser 游戏、AI 实验室——在不改动中文站(
/)的前提下,全量上线了英文版(/en/*)。没有子域名、没有语言协商服务、没有巨型 JSON,核心就三件事:URL 归一路由、文案按层分字典、SEO 用 hreflang 收口。
最近在维护儿童编程与音乐启蒙站点(芝麻园地)。这是基于 Astro 7 的纯静态站:博客 + 56 个前端工具 + 26 个互动教程 + AI 实验室 + 15 个 Phaser 游戏,托管在 CDN 上,没有后端、没有数据库。
做国际化的原因有两个:一是站点本身有海外访问需求,二是「游戏 + 英语」天然是一对——很多游戏本身就能当英语启蒙工具,界面中英切换正好补上这个场景。于是目标定为:中文站一行不动,全站新增英文版 /en/*,中文保持根路径 /,SEO 权重不分裂。
这篇把整个方案拆开讲讲:路由怎么归一一套、文案怎么按层组织、游戏运行时怎么切换语言、SEO 怎么收口,全是真实踩过的坑。
一、路由:[...lang] rest 路由 + routing: 'manual'
为什么用路径前缀而不是子域名
纯静态站没有服务端语言协商能力,/en/* 路径前缀对 CDN、缓存、爬虫最友好;子域名会分裂站点权重,?lang= 查询参数不利于分享与收录。
astro.config.mjs
export default defineConfig({
site: 'https://zhimayuandi.com',
i18n: {
locales: ['zh', 'en'],
defaultLocale: 'zh',
routing: 'manual', // 语言路由由 [...lang] 自行处理
},
// ...
});
routing: 'manual' 表示不用 Astro 内置的 Accept-Language 重定向与 URL 重写,语言完全由路由决定。代价是必须提供 src/middleware.ts(否则构建报错),中间件只做一件事——把当前语言写入 locals:
export const onRequest = defineMiddleware((context, next) => {
const { pathname } = new URL(context.url);
context.locals.lang = pathname.startsWith('/en') ? 'en' : 'zh';
return next();
});
getStaticPaths 生成两种语言
所有需要双语的页面放 src/pages/[...lang]/,用 getStaticPaths 生成两种语言:
export function getStaticPaths() {
return [
{ params: { lang: undefined }, props: { lang: 'zh' } }, // 匹配根路径 /
{ params: { lang: 'en' }, props: { lang: 'en' } }, // 匹配 /en
];
}
lang: undefined 是 rest 参数的小技巧:让中文页面落在 / 而不是 /zh,SEO 权重不分散。
语言切换器 LangSwitcher.astro 挂在导航上,用 src/i18n/paths.ts 的 toLangPath() 做「当前路径 ↔ 目标语言路径」换算,抽成纯函数,好测试。
二、文案:按层分字典,不搞一个巨型 JSON
这是整个方案里最重要的组织原则。按「作用域」拆成四层:
| 层 | 位置 | 内容 |
|---|---|---|
| 全局壳层 | src/i18n/ui.ts | 导航 / 页脚 / 面包屑 / 主题切换,typed dict 约束键对齐 |
| 页面级 | home.ts / tools-en.mjs / tutorial-en.mjs / games-en.mjs | 各栏目页 title / description / keywords / h1 |
| 组件级 | 组件内 TEXT = { zh, en } | React island 的按钮 / 提示 / 标签,lang prop 传入 |
| 游戏内 | src/games/*/i18n.ts | Phaser 场景内全部文案 |
全局壳层用 typed dict,漏一个键直接编译报错:
export interface UiLabels {
siteName: string;
skipLink: string;
home: string;
themeToggle: string;
switchToEn: string;
switchToZh: string;
}
export const uiDict: Record<Lang, UiLabels> = { /* zh / en 两套,键强制对齐 */ };
React 组件字典跟着组件走,而不是全局一把梭——组件可在不同页面复用,语言由容器注入:
const TEXT = {
zh: { copy: '复制', copied: '已复制' },
en: { copy: 'Copy', copied: 'Copied' },
};
游戏内用 en: Record<keyof typeof zh, string> 保证英文键一个不落;带 {n} 占位符的文案走模板替换。游戏内的静态数据(关卡、天气知识)走双语字段而不是塞进字典——数据是内容、文案是界面,两者分开维护。
三、游戏内运行时切换:URL → localStorage → 浏览器语言
Phaser 游戏是 Canvas 运行时渲染,没法用 Astro 模板插值,所以每个游戏自带 i18n.ts,检测顺序固定:
export function detectLang(): Lang {
// 1. URL 查询参数 ?lang=en(分享链接可直传)
const q = new URLSearchParams(location.search).get('lang');
if (q === 'zh' || q === 'en') return q;
// 2. URL 路径 /en/ 前缀(SEO / 分享的权威语言)
if (location.pathname.startsWith('/en')) return 'en';
// 3. localStorage:游戏内手动切换的记忆
const saved = localStorage.getItem('cloudCollector.lang');
if (saved === 'zh' || saved === 'en') return saved;
// 4. 浏览器语言兜底
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}
配合 setLang() / onLangChange() 就能在游戏内临时切换语言,选择按游戏作用域存 localStorage,不污染其他页面。
四、SEO 收口:canonical + hreflang + sitemap
国际化最容易被忽略的就是 SEO,做错直接造成重复内容。每个页面输出 canonical(指向自身)+ hreflang(双语互指):
<link rel="alternate" hreflang="zh-CN" href="https://zhimayuandi.com/blog/xxx/" />
<link rel="alternate" hreflang="en" href="https://zhimayuandi.com/en/blog/xxx/" />
<link rel="alternate" hreflang="x-default" href="https://zhimayuandi.com/blog/xxx/" />
@astrojs/sitemap 的 i18n 配置把语言代码映射到真实 locale,生成的 sitemap 自动带 alternate:
sitemap({ i18n: { defaultLocale: 'zh', locales: { zh: 'zh-CN', en: 'en' } } })
另外 <html lang>、og:locale(zh_CN / en_US)都按语言输出。
五、内容回退:英文缺翻译就标「中文」
博客走 Content Collections,英文文章放 posts/en/<slug>,没有翻译就回退中文原文并打「中文」徽标(ZhHint.astro),不误导英文读者:
if (lang === 'en') {
try { entry = await getEntry('posts', 'en', slug); }
catch { entry = undefined; }
if (!entry) {
entry = await getEntry('posts', slug); // 回退中文
isFallback = true; // 渲染「中文」徽标
}
}
原则是「回退 + 徽标」而不是「强制成对翻译」——先翻热门内容,冷门内容先回退,体验不受损,可以渐进式推进。
六、踩坑清单
| 坑 | 说明 | 正确姿势 |
|---|---|---|
| JS 硬编码写中文 | 游戏运行时 textContent = '开始' 写死中文,无法切换 | 建 i18n.ts 字典,运行时按 detectLang() 取值 |
字典放 src/pages/ | 会被 Astro 当成路由生成页面 | 统一放 src/i18n/ 或组件旁 |
| schema 加未声明字段 | 博客 frontmatter 写 lang: 等自定义字段会校验失败 | 只用 content.config.ts 声明的字段 |
| 忽略搜索索引 | posts/en/*.md 会生成 /blog/en/<slug> 的错 URL,索引仍单语 | 索引增加 lang 字段并按语言过滤 |
| 跨层状态串扰 | 用 React state 驱动游戏循环 / 在 React 里持 Phaser 实例 | 守项目边界:Phaser 写、React 读,单向数据流 |
另外,这次迁移 100+ 页面不是手改的,而是写了三个幂等脚本(migrate-tools-i18n.mjs / migrate-tutorials-i18n.mjs / migrate-games-i18n.mjs)扫描旧页面批量生成 [...lang] 版本,可重跑。
写在最后
Astro 静态站国际化的完整链路,三句话记住:
- 路由用
[...lang]+routing: 'manual',中文保持根路径,权重不分裂 - 文案按层分字典:壳层 ui.ts、页面模块、组件 TEXT、游戏 i18n.ts,typed dict 保证键对齐
- SEO 用 hreflang 收口,内容用「回退 + 徽标」渐进翻译
这套方案已经全量跑在线上:56 个工具、26 个教程、15 个游戏(其中 14 个游戏内置中英双语,古诗游戏等按内容属性保留中文单语)都能在导航右上角一键切换语言。
在线体验(免费,浏览器打开即玩):
- 芝麻园地(切右上角「中 / EN」看全站双语效果):zhimayuandi.com
- 英文版首页:zhimayuandi.com/en
- 完整技术复盘(含更多决策细节与完整代码):zhimayuandi.com/blog/astro-…
如果你也在给静态站做多语言,欢迎留言交流——尤其是「内容回退 vs 强制成对翻译」和「搜索索引怎么双语」这两个点,不同站点可能各有最优解。😄