Astro + i18n:从单语到中英双语静态站的完整落地记录

128 阅读6分钟

在线体验: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.tsPhaser 场景内全部文案

全局壳层用 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 静态站国际化的完整链路,三句话记住:

  1. 路由用 [...lang] + routing: 'manual',中文保持根路径,权重不分裂
  2. 文案按层分字典:壳层 ui.ts、页面模块、组件 TEXT、游戏 i18n.ts,typed dict 保证键对齐
  3. SEO 用 hreflang 收口,内容用「回退 + 徽标」渐进翻译

这套方案已经全量跑在线上:56 个工具、26 个教程、15 个游戏(其中 14 个游戏内置中英双语,古诗游戏等按内容属性保留中文单语)都能在导航右上角一键切换语言。

在线体验(免费,浏览器打开即玩):

如果你也在给静态站做多语言,欢迎留言交流——尤其是「内容回退 vs 强制成对翻译」和「搜索索引怎么双语」这两个点,不同站点可能各有最优解。😄