Web 国际化
国际化是一种架构设计,而不是项目末期的翻译工作。目标是构建一个能正确渲染任何区域设置的UI,无需特殊处理:没有拼接的句子,没有硬编码的格式,没有因德语文本长35%或阿拉伯语翻转方向而导致布局破坏的情况。
何时使用本技能
当用户提出以下要求时使用:
- 添加语言、语言切换器或区域路由
- 翻译应用、提取字符串或设置翻译工作流
- 修复复数、性别或“1 item / 2 items”相关的错误
- 按区域设置格式化日期、数字、货币、相对时间或列表
- 支持RTL(阿拉伯语、希伯来语、波斯语、乌尔都语)
- 添加hreflang、本地化元数据或多语言站点地图
- 审查现有的i18n配置、缺失的键或翻译漂移
- 在next-intl、react-i18next、FormatJS、Lingui或TMS之间做出选择
相关参考资料位于references/,脚本位于scripts/。仅读取任务所需的内容。路径相对于本技能所在的目录(技能加载时,代理运行时会打印该路径)。
不可妥协的原则
- 绝不通过拼接翻译片段来构建句子。 使用带占位符的完整消息进行翻译:
"{count} new messages",而不是t('new') + ' ' + count + ' ' + t('messages')。 - 绝不使用英文字符串作为键。 键是稳定的标识符(
inbox.unreadCount),这样文案修改不会导致查找失败或使所有翻译失效。 - 每个面向用户的字符串都必须外部化,包括
aria-label、alt、title、placeholder、验证错误、空状态、提示、邮件、推送通知和PDF输出。 - 绝不硬编码格式。 不使用
toFixed(2) + '$',不手动写MM/DD/YYYY,不使用英文月份名称,不假设24小时制。应使用Intl。 - 区域设置是一个BCP 47标签,而不仅仅是语言。
pt-BR和pt-PT、zh-Hans和zh-Hant、es-MX和es-ES是不同的产品。请存储完整的标签。 - 服务器上的区域状态必须按请求范围隔离。 在SSR期间,绝不要将当前区域设置放在模块级别的可变变量中;并发请求会导致语言互相泄漏。
- 如果翻译来自TMS、社区或CMS,则视为不受信任的输入。 在未进行安全处理的情况下,绝不要将其传递给
innerHTML/dangerouslySetInnerHTML。
工作流
1. 修改代码前先审计
- 在提出建议前,先检测所使用的技术栈、路由器和现有的i18n库。
- 查找硬编码的字符串:
将输出视为候选列表,而非确定结果:跳过测试夹具、常量、URL和面向开发者的日志。node scripts/scan-hardcoded.mjs src - 清点当前已交付的每个区域设置以及目录的存放位置。
- 优先识别风险最高的区域:认证、结账、错误页面、邮件和法律声明。
2. 确定架构(书面记录)
在编写代码前回答以下问题:
| 决策项 | 默认值 | 说明 |
|---|---|---|
| 区域策略 | 路径前缀 /es/ | SEO和缓存效果最佳。仅在确有理由时才使用子域名/国别顶级域。 |
| 默认区域 | 源语言 | 不要在未经用户覆盖的情况下,自动将首次访问者重定向到猜测的区域。 |
| 区域检测 | URL → Cookie → Accept-Language → 默认 | URL优先,这样共享链接不会改变语言。 |
| 目录格式 | 每个区域每个命名空间一个JSON文件 | 按需加载命名空间;切勿将所有区域设置都发送到客户端。 |
| 消息语法 | ICU MessageFormat | 可跨库和TMS移植;支持复数/选择/序数。 |
| 键样式 | feature.component.element | 稳定、可全局搜索、不包含英文字符串。 |
| 翻译数据源 | TMS或代码仓库,二选一 | 只能选择一个;在CI中单向同步。 |
| 回退 | 区域链 es-MX → es → en | 在开发环境中记录回退情况以发现缺口。 |
3. 外部化并组织消息
- 将字符串替换为语义化键,并将原文复制到基础区域目录中。
- 每个完整句子使用一个键。如果句子包含链接或粗体字,应使用富文本插值而不是拆分句子。
- 占位符应使用命名方式(
{userName}),不要使用位置索引({0})。 - 除非库明确支持类型化标签,否则不要在目录中放入HTML。
- 按产品面而不是按页面分组:
checkout.json、auth.json、errors.json。
关于复数、选择、序数和富文本的规则,请参阅 references/icu.md。
4. 配置区域路由和渲染
- 从路由中提取区域设置,验证其是否在支持列表中,对未知区域应返回404而不是静默回退。
- 提供语言切换器,并保留当前路径和查询字符串。
- 在服务器渲染的文档中设置
<html lang="...">和<html dir="...">。 - 仅加载当前活动区域的目录以及默认回退目录。
- 确保同一目录可同时用于服务端和客户端渲染;避免重复或分叉的翻译逻辑。
框架特定的设置和陷阱:references/frameworks.md。
路由、检测、hreflang和站点地图:references/routing-seo.md。
5. 处理区域敏感值
使用 Intl(或框架的封装),绝不手动格式化:
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date)
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-3, 'day')
new Intl.ListFormat(locale, { type: 'conjunction' }).format(items)
new Intl.PluralRules(locale).select(count)
new Intl.Collator(locale).compare(a, b)
- 时间戳以UTC存储;在用户的时区渲染,而不是服务器的时区。
- 货币与区域设置不是同一回事:法国用户可能用美元支付。
- 使用
Intl.Collator进行排序/搜索,而不是使用<或不指定区域的localeCompare()。 - 大小写转换:使用
toLocaleUpperCase(locale),因为土耳其语中的i会变成İ。
6. 考虑文本扩展和RTL
- 假设文本长度增加30–40%(德语、芬兰语、俄语),避免固定宽度的按钮或单行标签。
- 让容器可以扩展,允许换行,并通过伪本地化进行测试:
node scripts/pseudo-localize.mjs locales/en.json --out locales/en-XA.json - 使用逻辑CSS属性(
margin-inline-start、padding-inline、inset-inline)替代left/right。 - 镜像方向性图标(箭头、V形符号、进度条),但不要镜像Logo、时钟、媒体控件或对勾。
- 将用户生成的内联内容包裹在
<bdi>或dir="auto"中。
完整的RTL检查清单:references/rtl.md。
7. 在CI中设置翻译质量检查
至少要在目录结构无效时构建失败:
node scripts/check-locales.mjs locales --base en
该命令会检查缺失/多余的键、空值、占位符不匹配以及破损的ICU语法。在可用时,通过库的CLI添加ICU编译:
npx formatjs compile-folder --ast locales/en locales/compiled/en
添加针对复数类别、固定时区下的日期/数字输出以及RTL渲染的测试。请参阅 references/testing.md。
关键决策与陷阱
库的选择
| 需求 | 选择 |
|---|---|
| Next.js App Router,服务端组件 | next-intl |
| React SPA,成熟生态,插件丰富 | react-i18next |
| ICU优先,框架无关,CLI提取 | FormatJS (react-intl) |
| 编译时目录,小型运行时,类型安全 | Lingui |
| Vue | vue-i18n |
| Svelte | svelte-i18n 或 paraglide |
| Astro | 内置i18n路由 + 消息库 |
| 原生JS / 最小化 | Intl + 小型目录加载器 |
不要向已有i18n库的应用中引入第二个i18n库。应扩展现有配置。
复数
复数类别因语言而异。英语有 one/other;阿拉伯语有 zero/one/two/few/many/other;日语只有 other。绝不要写 count === 1 ? ... : ...。
"inbox.unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
性别与语法一致
使用 select 处理性别变量,并保持变量值语言中立(female, male, neutral, unknown),而不是本地化的词汇:
"profile.greeting": "{gender, select, female {Bienvenida, {name}} male {Bienvenido, {name}} other {Bienvenido/a, {name}}}"
如果某种语言需要在形容词、名词或冠词上进行一致变化,应将语法性别/格作为参数公开,而不是通过字符串拼接。
富文本
不要将 "Read our " + link + " terms" 拆分为三个键。应使用标签插值:
"signup.terms": "I agree to the <link>Terms of Service</link>"
t.rich('signup.terms', { link: (chunks) => <a href="/terms">{chunks}</a> })
SEO
- 每个区域有一个可索引的URL;绝不要在同一个URL下提供机器翻译的内容。
- 自引用
hreflang加上x-default;使用绝对URL;包含所有区域。 - 本地化
<title>、meta描述、OG标签和结构化数据。 - 不要在本地化页面上同时使用
hreflang和noindex。
详细信息见 references/routing-seo.md。
缓存
- 将区域设置在路径中可使CDN缓存变得简单。如果通过请求头协商,发送
Vary: Accept-Language,并预期缓存碎片化。 - 不要在共享CDN响应中缓存个性化的语言偏好。
- 保持
Content-Language与渲染的区域设置一致。
审查清单
在宣布i18n完成之前:
- 组件、邮件或错误中不存在硬编码的面向用户字符串
- 没有句子拼接;每条消息都是一个完整的可翻译单元
- 键是语义化且稳定的;不使用英文字符串作为键
- 每个占位符在每个区域中都存在;ICU语法可编译
- 复数使用
Intl.PluralRules/ICU,而不是count === 1 - 性别/一致问题通过
select处理,而不是"/a"后缀 - 日期、数字、货币、相对时间使用
Intl,并显式指定区域/时区 -
<html lang>和dir在首次渲染时(SSR)正确设置,不是在effect中设置 - 区域切换器保留路径/查询字符串,并能处理深层链接
- 未知区域返回404,而不是静默回退
- 布局能承受+35%的扩展和RTL
-
hreflang、规范URL、站点地图和本地化元数据正确 - 目录验证在CI中运行
- 回退行为是有意设计且可观察的
反模式
t('hello') + ', ' + name—— 破碎的句子key={englishText}—— 翻译后的文本作为键count === 1 ? 'item' : 'items'—— 仅适用于英语的复数逻辑- 不带区域或时区的
new Date().toLocaleDateString() - 使用
value.toFixed(2)处理货币 - 分散在各个组件中的
locale === 'ar' && { direction: 'rtl' },而不是文档级别的dir - 在
useEffect中设置<html lang>—— 会导致闪烁并影响爬虫 - 将所有区域设置加载到初始包中
- 根据IP自动重定向,且无法覆盖
- 翻译专有名词、产品名称、代码标识符或URL
- 机器翻译字符串后未经审查就发布
- 假设一个目录文件可以容纳几百个以上的键