不用框架的 i18n:三语站点手写路由的代价
这个站点的手写 i18n 没用框架自带的配置。说说为什么,以及代价具体出现在哪里——两次 404,和一半没翻的文章。
语言这块我没用 Astro 自带的 i18n 配置,自己写了一套。理由不是“框架做得不好”,而是多语言在这个站上不是一个渲染问题,是一个内容管理问题:把文案换成英文是简单的那一半,让三种语言的结构不漂移才是难的那一半。
手写的是什么
三件事:
- 一个
[lang]动态路由。页面文件只有一份,zh/en/ja共用同一个.astro。 - 内容按语言分目录:
src/content/blog/<lang>/<slug>.md,语言从 entry id 的前缀取。 - 一份文案字典:
src/lib/i18n.ts里的LOCALES、LOCALE_META、t(locale, key, vars)。
路由生成的真相只有一处 —— src/server/paths.ts。页面只 export const getStaticPaths = xxxPaths,不自己拼地址。
与框架 i18n 的差别
| 框架的 i18n 配置 | 手写 | |
|---|---|---|
| 路由前缀 | 按配置生成 | 我在 [lang] 里显式处理 |
| 语言检测重定向 | 框架接管 | 我自己在 / 上做 |
| 缺翻译时的行为 | 回退到默认语言,静默 | 那门语言里根本没有这篇 |
| 类型 | 框架的 locale 类型 | 我自己的 Locale,编译期能收窄 |
| 出错时的位置 | 框架内部 | 我的代码里,读得到 |
第三行是决定性的。「回退到默认语言」听着体贴,实际效果是英文读者点开一篇文章,看到的是中文。我宁可空着,也不要读者以为自己点错了。
静默回退会掩盖内容缺失。缺了就该缺得看得见。
代价一:路径必须只有一处生成
手写 i18n 最容易犯的错,是把语言前缀当成「随手拼接的字符串的一部分」。
我踩过:appHref(kind, slug) 返回 /tools/convert —— 没有语言前缀。而真实路由是 /[lang]/[kind]/[slug]/,于是首页和两个列表页上的每一个应用入口都 404。更麻烦的是中文站看起来完全正常,因为那条路径上别的地方正好补了前缀,只有切到英文才暴露。
修法不是「记得加前缀」,是把拼路径这件事关进唯一的函数:
// src/server/meta.ts
export function href(locale: string, path = '/'): string {
const normalized = path.startsWith('/') ? path : '/' + path;
return '/' + locale + (normalized === '/' ? '/' : normalized);
}
现在没有任何页面或组件自己拼地址。要加一条路由,就在 server/paths.ts 里加一个 xxxPaths。
代价二:三语齐全靠纪律,不靠代码
内容一个语言一个文件,所以「翻译没跟上」在构建期完全合法:构建照过,页面照出,只是英文站少几篇。
我实际的状态曾经是中文 3 篇,英文 1 篇,日文 1 篇。列表、归档、标签、RSS 全都从那门语言的文章数组里长出来,所以英文读者看到的博客只有一篇文章,而中文版一切正常。
这件事没有技术解,只能靠规则 + 文档。我把它写进了给 agent 看的撰写规范:
加了
zh/x.md就同时加en/x.md与ja/x.md。缺一门语言,那门语言的列表、归档、标签、RSS 里就不会有这篇。
配套的还有一张标签词表 —— 标签按语言各写各的,不做跨语言对齐,所以词表得逐语言维护,否则很快会出现 性能 / パフォーマンス / performance 三个标签各指一篇的碎片状态。
现在这套东西的形状
- 加一种语言:改
LOCALES、LOCALE_META、文案字典,往content/blog/加目录。 - 加一篇文章:加三个文件,别的什么都不用动。
- 改导航文案:只改字典,三语各一条。
代价是得自己记住这些约定,框架不会替你检查。收益是所有行为都在我读得懂的代码里,没有一层我控制不了的回退。
语言方案的价值不在于“支持多语言”,而在于缺失时它表现得诚实。

评论
…