返回

无需翻译文件的 i18n

Jackie Chen avatarJackie Chen
guideinternationalizationnextjsi18ngt-nexttranslation-filesdeveloper-experience

做过 JavaScript 应用国际化的人都熟悉这套流程。你安装一个 i18n 库,创建一个 en.json 文件,把组件里所有面向用户的字符串都抽离出来,给每一条分配一个 key,再在原来写字符串的地方引用这个 key。然后,你还得为支持的每种语言各复制一份这样的 JSON 文件。es.jsonfr.jsonja.json

一开始,这似乎没什么问题。30 个字符串,3 种语言,90 条条目。

但随着应用规模变大,情况就不同了。六个月后,你有了 400 个字符串和 12 种语言。十几个文件里一共 4,800 条条目。某个开发者加了一个新功能,写了 5 条新字符串,却忘了更新其中 3 个翻译文件。直到东京的一位用户在日语界面里看到了英文后备内容,才有人发现问题。于是就有人提议去买一套翻译管理系统。

文件管理的额外负担

翻译文件会带来维护负担,而且这种负担会随着你支持的语言数量增加而不断加重。改动一个 string,就得更新十几个文件。这些文件还会逐渐失去同步。要让它们保持一致,就需要工具、自动化,或者严格的流程约束。

这一整套权宜之计的生态,本身就很能说明问题:自动生成翻译键的 VS Code 扩展、根据 TypeScript 接口验证 JSON 的类型生成器、检查未使用键的 linter、验证所有区域设置是否保持 sync 的 CI 检查。一个真正高效的工作流,不会需要你专门构建这类工具。

你的 UI 表达的是一回事,翻译文件写的又是另一回事,而两者之间只是靠一层基于键的映射勉强维系:

function CheckoutSummary({ itemCount, discount }) {
  const { t } = useTranslation('checkout');

  return (
    <div>
      <h2>{t('summary.title')}</h2>
      <p>{t('summary.item_count', { count: itemCount })}</p>
      {discount && <p>{t('summary.discount_applied', { percent: discount })}</p>}
      <p>{t('summary.total_label')}</p>
      <small>{t('summary.tax_notice')}</small>
    </div>
  );
}

要理解这个组件实际会渲染什么内容,你得打开 checkout.json,找到 summary 命名空间,再逐一对照五个键。在数百个组件的规模下,代码审查很快就会变成一场不停来回翻文件的苦差事。

ICU 消息语法会让情况雪上加霜。i18nextreact-intlnext-intl 这类格式化库用于处理复数、性别和插值的格式,本质上是一种嵌在 JSON 字符串里的小型语言。像 {count, plural, one {# item} other {# items}} 这样的写法,只要有一个花括号放错位置,问题往往要到 Runtime 才会暴露出来。虽然也有针对这种情况的 linter,但大多数团队都不会用。相比之下,内联代码从你输入的那一刻起,就会立即受到 TypeScript compiler 和 IDE 的检查。

直接翻译代码

如果根本不提取字符串,会怎么样?

import { T } from 'gt-react';

function CheckoutSummary({ itemCount, discount }) {
  return (
    <T>
      <div>
        <h2>Order Summary</h2>
        <p>You have {itemCount} items in your cart.</p>
        {discount && <p>{discount}% discount applied!</p>}
        <p>Total (before tax):</p>
        <small>Tax calculated at checkout.</small>
      </div>
    </T>
  );
}

gt-react 中的 <T> 组件用于将一段 JSX 标记为可翻译内容。英文内容会保留在组件中。当用户以西班牙语访问时,<T> 内的内容会替换成对应的西班牙语版本,结构和格式也会一并保留。

无需 t('checkout.summary.title'),无需 en.json,也无需维护并 sync 各个区域设置对应的 JSON 文件。

作为构建产物生成的翻译

这些翻译会在部署时生成。GT CLI 会扫描你的代码库中 <T> 组件内的所有内容,并为每种目标语言生成翻译。输出结果会写入一个被 git 忽略的目录,类似于编译后的 CSS 或打包后的 JavaScript。

{
  "defaultLocale": "en",
  "locales": ["es", "fr", "ja", "de", "ko", "zh"],
  "files": {
    "gt": {
      "output": "public/_gt/[locale].json"
    }
  }
}
public/_gt/

在开发环境中,翻译按需 (on-demand) 进行。改动一个字符串,刷新页面,就能立即看到它的日语版本。在生产环境中,所有内容都会预先生成并通过 CDN 分发。翻译文件仍然存在于磁盘上,但它们是构建输出产物,不是需要你手动编写或维护的内容。

在实际工作中有哪些变化

这种差异更多体现在日常开发里,而不是架构图上。

一个给页面新增版块的 PR,看起来就只是一个给页面新增版块的 PR。审阅者看到的是实际文案,而不是 checkout.summary.discount_applied_notice。代码审查也不再需要和 JSON 文件并排对照。

重构也没那么痛苦了。重命名组件、移动组件、把它拆开成几个部分,翻译都会随内容一起走,因为没有需要重新映射的键。新增一种语言只需要改一行配置。删除一个组件时,它对应的翻译也会悄然停止生成,而不是在 15 个没人愿意清理的文件里留下孤立的键。

i18n 库的发展方向

整个生态的趋势都在转向内联字符串。早期的 i18n 库,如 i18nextreact-intl,诞生于机器翻译尚不可行、每个字符串都必须交由人工译者处理的时代。字典作为一种中间交换格式,当时是合理的。如今这一限制已经消失,而维护一套并行字符串文件所带来的开发体验成本,也越来越难以 justify。

next-intl 在字典模式之外增加了非字典的 t() 调用Lingui 的 Compiler 会在构建时从内联 tagged template 中提取消息。Paraglide 则走了另一条路线:从消息文件编译成按区域设置划分、可进行 tree-shaking 的函数。实现方式虽然不同,但这些方案都在让内容更贴近组件。GT 则将这一趋势推到了终点:你的 JSX 就是单一事实来源,而翻译是编译过程中的一个步骤。

这种取舍

当你以内联方式编写内容时,其实就是在用自己的母语写作。组件结构、句式习惯、UI 流程,都会反映出你用英语 (或任何源语言) 思考的方式。而像 next-intl 这种基于字典的方法,从设计上就更不依赖具体语言,因为组件里从来不直接包含任何语言的完整句子,只有一个指向别处的键。

但大多数开发者在构建 UI 时,本来就是先用一种语言来思考。布局、文案、按钮标签,往往也都是先用英语构思的。无论这些字符串是内联写的,还是放在 JSON 文件里,这种偏向都会体现在设计中。我们认为,i18n 框架应该适应你实际的工作方式:先按自然的方式构建应用,再由框架处理翻译,而不是为了追求语言中立而把内容抽象成键。

快速开始

npx gt@latest init

设置向导会配置你的项目、安装依赖,并为开发环境启用翻译热重载。完整说明请参阅快速入门指南

gt-react 是开源项目。对于 Next.js App Router,可使用 gt-next。对于 React Native,可使用 gt-react-native