跳到主内容
Zhimalab
EN

Astro 静态站国际化最佳实践:从单语到中英双语的完整落地

2026-08-19 · 27 分钟

本站(芝麻园地 zhimalab)是一个基于 Astro 7 的纯静态站点:博客 + 50+ 前端工具 + 互动教程 + AI 实验室 + 15 个 Phaser 游戏。2026 年 8 月起,我们在保持中文站(/)完全不变的前提下,为全站新增了英文版(/en/*)。本文把这一轮国际化的完整方案、分层模型和踩坑清单整理出来,供后续做多语言的 Astro 静态站参考。

一句话结论:Astro 静态站的国际化,本质是三件事——URL 归一路由、文案按层分字典、SEO 用 hreflang 收口。

现状速览(2026-08 完成度)

范围 数量 状态
首页 / 栏目页 dev / kids / nav / ai(5) / blog(2) / search / admin ✅ 双语壳层
工具页 56 ✅ 壳层双语 + 组件 UI 主体已翻
教程页 26 ✅ 壳层双语(课程数据仍中文)
游戏页 15 ✅ 壳层双语(游戏内文案已翻 4 个)
博客 23 篇 🟡 /en/blog/* 壳层就绪,内容回退中文
搜索索引 1 🟡 仍单语 zh

一、URL 与路由:[...lang] + 手动路由

1.1 决策:路径前缀,不用子域名

采用 /en/* 前缀,中文保持根路径 /

  • //en
  • /blog/xxx/en/blog/xxx

原因:纯静态站没有服务端语言协商能力,路径前缀对 CDN、缓存、爬虫最友好;子域名会分裂站点权重,?lang= 查询参数不利于分享与收录。

1.2 astro.config.mjs 配置

export default defineConfig({
  site: 'https://zhimalab.tech',
  i18n: {
    locales: ['zh', 'en'],
    defaultLocale: 'zh',
    routing: 'manual', // 语言路由由 [...lang] 自行处理
  },
  integrations: [
    react({ include: ['**/*.jsx', '**/*.tsx'] }),
    mdx(),
    sitemap({
      i18n: { defaultLocale: 'zh', locales: { zh: 'zh-CN', en: 'en' } },
    }),
  ],
});

关键点: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();
});

1.3 [...lang] rest 路由 + getStaticPaths

所有需要双语的页面都放在 src/pages/[...lang]/ 下,用 getStaticPaths 生成两种语言:

export function getStaticPaths() {
  return [
    { params: { lang: undefined }, props: { lang: 'zh' } }, // 匹配根路径 /
    { params: { lang: 'en' }, props: { lang: 'en' } },      // 匹配 /en
  ];
}

注意 lang: undefined 这个技巧:[...lang] 是 rest 参数,undefined 让它匹配顶层路径,中文首页就是 / 而不是 /zh,SEO 权重不分散。

1.4 语言路径换算工具 src/i18n/paths.ts

导航里的语言切换器需要「当前路径 ↔ 目标语言路径」的换算,抽成纯函数避免散落各处:

export function langPrefix(lang: Lang): string {
  return lang === 'en' ? '/en' : '';
}

export function toLangPath(pathname: string, target: Lang, from: Lang): string {
  if (target === from) return pathname;
  // 首页特例:/ ↔ /en
  // 其余路径只做前缀增删,如 /blog/xxx → /en/blog/xxx
}

LangSwitcher.astro 挂在导航上,调用 toLangPath(Astro.url.pathname, target, lang) 得到目标语言 URL。

二、文案字典:按层分文件,不搞一个巨型 JSON

这是整个方案里最重要的组织原则。按「作用域」把文案拆成四层:

位置 内容 消费方
全局壳层 src/i18n/ui.ts 导航 / 页脚 / 面包屑 / 主题切换 / 搜索占位符 AppLayout.astroLangSwitcher
页面级 src/i18n/home.tstools-en.mjstutorial-en.mjsgames-en.mjs 各栏目页 title / description / keywords / h1 [...lang]/*.astro
组件级 组件内 TEXT = { zh, en } React island 的按钮 / 提示 / 标签 React 组件(lang prop 传入)
游戏内 src/games/*/i18n.ts Phaser 场景内全部文案 游戏运行时

2.1 全局壳层:typed dict

ui.ts 用一个接口约束两种语言的键完全对齐,漏一个键直接编译报错:

export interface UiLabels {
  siteName: string;
  skipLink: string;
  home: string;
  blog: string;
  themeToggle: string;
  searchPlaceholder: string;
  switchToEn: string;
  switchToZh: string;
}

export const uiDict: Record<Lang, UiLabels> = {
  zh: { siteName: '芝麻园地', skipLink: '跳到主内容', /* ... */ },
  en: { siteName: 'Zhimalab', skipLink: 'Skip to main content', /* ... */ },
};

AppLayout.astro 接收 lang prop,nav / footer / 面包屑全部走 uiDict[lang],模板里不再出现裸中文。

2.2 组件级:TEXT = { zh, en } + lang prop

React island 拿不到 Astro 的 Astro.currentLocale,约定每个组件自带字典,通过 ThemeProviderWrapper 统一向下传 lang

const TEXT = {
  zh: { copy: '复制', copied: '已复制' },
  en: { copy: 'Copy', copied: 'Copied' },
};

function CopyButton({ lang = 'zh' }) {
  const t = TEXT[lang];
  return <button>{t.copy}</button>;
}

原则:字典跟着组件走,而不是全局一把梭。 这样组件可以在不同页面 / 项目间复用,语言由容器注入,组件本身无感知。

2.3 游戏内:i18n.ts 字典 + 双语数据字段

Phaser 游戏是运行时 Canvas 渲染,无法用 Astro 模板插值,所以每个游戏自带 i18n.ts。以 cloud-collector 为例,采用「zh 为基准、en 强制键对齐」的写法:

const zh = {
  menuTitle: '云朵收收乐',
  modeSingle: '单人闯关',
  helpLine1: '1. 拖动同色云朵到一起,它们会合并成更大的云团',
  score: '分数: {n}',
  // ...约 60 键
} as const;

const en: Record<keyof typeof zh, string> = {
  menuTitle: 'Cloud Collector',
  modeSingle: 'Single Player',
  helpLine1: '1. Drag same-color clouds together to merge them into a bigger cloud',
  score: 'Score: {n}',
  // ...
};

en: Record<keyof typeof zh, string> 保证英文键一个不落;带 {n} 占位符的文案用模板替换函数渲染。

游戏内的静态数据(关卡、主题、天气知识)走双语字段,而不是塞进字典——数据是内容、文案是界面,两者分开维护:

// data/levels.ts
{
  name: '云朵初现',
  nameEn: 'First Clouds',
  description: '拖动同色云朵合并',
  descriptionEn: 'Drag same-color clouds to merge',
}

配合 localizedName() 这类辅助函数,渲染时按当前语言取字段:

export const localizedName = (d: { name: string; nameEn: string }, lang: Lang) =>
  lang === 'en' ? d.nameEn : d.name;

三、三种「国际化粒度」:壳层 / 数据 / 内容

Astro 站点的国际化要分三种粒度处理,不能一刀切:

3.1 页面壳层(结构文案)

导航、页脚、面包屑、meta 标签、按钮——这些是「页面框架」,用字典 + lang prop 解决。AppLayout 负责:

  • <html lang> 按语言输出
  • og:locale 按语言输出(zh_CN / en_US
  • en 页自动输出 hreflang 三连(en-US / zh-CN / x-default

3.2 结构化数据(列表 / 卡片)

工具名、游戏名、课程标题这类结构数据,给每条数据加双语字段(name / nameEn),列表页按 lang 选择。好处:列表、卡片、JSON-LD 共用同一份数据,不重复翻译。

3.3 内容集合(博客文章)

博客走 Astro Content Collections。content.config.ts 声明 posts 集合支持双语言目录:

const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  locales: ['zh', 'en'], // 英文文章放 posts/en/
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    date: z.union([z.string(), z.date()]),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

详情页 [slug].astro 的渲染策略:英文版优先读 posts/en/<slug>,没有翻译就回退中文原文,并打「中文」标签

if (lang === 'en') {
  try {
    entry = await getEntry('posts', 'en', slug);
  } catch {
    entry = undefined;
  }
  if (!entry) {
    entry = await getEntry('posts', slug); // 回退中文
    isFallback = true; // 渲染 ZhHint「中文」徽标
  }
}

ZhHint.astro 是一个小徽标组件,在英文页面上标注「这里仍是中文」,避免误导英文读者。阅读时间也按语言计算:estimateReadingTime(entry.body, lang) 按 350 字/分钟估算,中文输出「X 分钟」、英文输出「X min」。

结论:内容集合用「回退 + 徽标」而不是「强制成对翻译」,可以渐进式推进——先翻热门文章,冷门文章先回退,读者体验不受损。

四、SEO 收口:canonical + hreflang + sitemap

国际化最容易被忽略的就是 SEO,做错会直接造成重复内容问题。

4.1 每个页面输出 canonical(指向自身)+ hreflang(双语互指)

<link rel="alternate" hreflang="zh-CN" href="https://zhimalab.tech/blog/xxx/" />
<link rel="alternate" hreflang="en" href="https://zhimalab.tech/en/blog/xxx/" />
<link rel="alternate" hreflang="x-default" href="https://zhimalab.tech/blog/xxx/" />

zh 页面由页面显式传 hreflang prop 给 AppLayout;en 页面由 AppLayout 自动生成指向中文版的 alternate(toLangPath 换算路径)。

4.2 sitemap 带语言映射

@astrojs/sitemapi18n 配置把语言代码映射到真实 locale(zhzh-CN),生成的 sitemap 会自动带上每个 URL 的 alternate 链接:

sitemap({
  i18n: { defaultLocale: 'zh', locales: { zh: 'zh-CN', en: 'en' } },
})

五、游戏内运行时语言:URL → localStorage → 浏览器语言

游戏是运行时场景,语言可能需要在游戏内临时切换(不影响页面壳层)。采用双层语言模型

  1. 站点 locale(由 URL 决定):负责 SEO、分享、页面壳层
  2. 应用级覆盖(游戏内):默认继承 URL locale,可运行时切换,存 localStorage

检测顺序(cloud-collector 等已完成游戏的范式):

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';
}

六、踩坑清单(反模式)

这一轮真实踩过的坑:

说明 正确姿势
JS 硬编码写中文 bubble-words / mingdrum 等游戏在运行时用 textContent = '开始' 写中文,无法切换 i18n.ts 字典,运行时按 detectLang() 取值
字典放 src/pages/ 会被 Astro 当成路由生成页面 统一放 src/i18n/ 或组件旁
schema 加未声明字段 博客 frontmatter 写 lang: 等自定义字段会校验失败 只用 content.config.ts 声明的字段
忽略搜索索引 generate-search-index.mjs 的 slug 取自相对路径,posts/en/*.md 会生成 /blog/en/<slug> 的错 URL,且索引内容仍单语 索引增加 lang 字段并按语言过滤(D6 待实施)
跨层状态串扰 用 React state 驱动游戏循环 / 在 React 里持 Phaser 实例 守项目边界:Phaser 写、React 读,单向数据流
getEntry 签名混淆 getEntry('posts', 'en', slug) 的三参写法与旧版二参签名容易混淆 升级 Astro 时确认该 API 形态是否保留

另外两个已知边界(决策保留,不算坑):

  • WebUI 88 页:截图 / 演示页,只翻壳层不翻正文
  • poem-mage(古诗游戏):内容强依赖中文,保留单语

七、可复用的迁移脚本

这次国际化不是手改 100+ 文件,而是写了三个幂等迁移脚本,扫描旧页面自动生成 [...lang] 版本:

脚本 作用
scripts/migrate-tools-i18n.mjs 56 个工具页 → [...lang]/tools/*.astro
scripts/migrate-tutorials-i18n.mjs 26 个教程页 → [...lang]/tutorial/*.astro
scripts/migrate-games-i18n.mjs 15 个游戏页 → [...lang]/games/*.astro(含全屏脚本 define:vars 注入)

八、总结

Astro 静态站国际化的完整链路:

astro.config i18n 配置


[...lang] rest 路由 + getStaticPaths   ← URL 归一路由

        ├── 页面壳层 → src/i18n/ui.ts 字典(AppLayout 消费)
        ├── 页面级   → home.ts / tools-en.mjs 等模块字典
        ├── 组件级   → TEXT = { zh, en } + lang prop
        ├── 游戏内   → i18n.ts 字典 + 双语数据字段 + detectLang()
        └── 内容集合 → posts/en/*.md 回退中文 + ZhHint


canonical + hreflang + sitemap(i18n) + og:locale   ← SEO 收口

三句话记住这套方案:

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