Creation:2025-09-10Last update:2025-09-10

    每组件(Per-Component)与集中式(Centralized)i18n

    每组件(per-component)方法并非新概念。例如,在 Vue 生态系统中,vue-i18n 支持 SFC i18n(单文件组件)。Nuxt 也提供 按组件翻译,Angular 通过其 Feature Modules 采用类似的模式。

    即使在 Flutter 应用中,我们也常能发现这样的模式:

    lib/
    └── features/
        └── login/
            ├── login_screen.dart
            └── login_screen.i18n.dart  # <- 翻译存放在这里
    import 'package:i18n_extension/i18n_extension.dart';
    
    extension Localization on String {
      static var _t = Translations.byText("en") +
          {
            "Hello": {
              "en": "Hello",
              "fr": "Bonjour",
            },
          };
    
      String get i18n => localize(this, _t);
    }

    然而在 React 领域,我们主要看到不同的做法,我会将它们分为三类:

    集中式方法(i18next、next-intl、react-intl、lingui)

    • (无命名空间)将内容视为单一来源进行检索。默认情况下,当应用加载时,会从所有页面加载内容。

    细粒度方法 (intlayer, inlang)

    • 按键或按组件对内容检索进行细化。

    在本博文中,我不会专注于基于编译器的解决方案,我已经在这里覆盖过:Compiler vs Declarative i18n. 注意,基于编译器的 i18n(例如 Lingui)只是自动化了内容的提取和加载。在底层,它们通常与其他方法共享相同的限制。

    注意,你越细化内容的检索方式,就越有可能将额外的 state 和逻辑插入到组件中。

    细粒度方法比集中式方法更灵活,但这通常是一种权衡。即使这些 libraries 宣称支持 "tree shaking",在实际中,你通常仍会以每种语言加载整个页面。

    所以,概括来说,决策大致可以分为:

    • 如果你的应用页面数量多于语言数量,应优先采用细粒度方法。
    • 如果语言数量多于页面数量,则应倾向于集中式方法。

    当然,library 的作者们也意识到这些限制并提供了解决方案。 其中包括:将内容拆分为命名空间、动态加载 JSON 文件(await import()),或在构建时剔除内容。

    与此同时,你应该知道,当你动态加载内容时,会向服务器引入额外的请求。每多一个 useState 或 hook,就意味着一次额外的服务器请求。

    为了解决这一点,Intlayer 建议将多个内容定义分组到同一个键下,Intlayer 会合并这些内容。

    但综观这些解决方案,最流行的方法显然是集中式的。

    那么为什么集中式方法如此受欢迎?

    • 首先,i18next 是第一个被广泛采用的解决方案,其理念受 PHP 和 Java 架构(MVC)的启发,依赖于严格的关注点分离(将内容与代码分离)。它于 2011 年出现,在向基于组件的架构(如 React)大规模转变之前就确立了其标准。
    • 此外,一旦某个库被广泛采用,就很难将生态系统转向其他模式。
    • 在 Crowdin、Phrase 或 Localized 等翻译管理系统中,使用集中式方法也更为方便。
    • 按组件(per-component)方法背后的逻辑比集中式更复杂,开发需要更多时间,尤其是在需要解决诸如识别内容位置等问题时。

    好的,但为什么不直接坚持集中式方法?

    让我告诉你这对你的应用可能会带来哪些问题:

    • 未使用的数据: 当一个页面加载时,你通常会加载来自所有其他页面的内容。(在一个 10 页的应用中,那就是 90% 未使用的内容被加载)。你懒加载一个 modal?i18n 库并不在意,它反正会先加载这些字符串。
    • 性能: 每次重新渲染时,你的每个组件都会被一个巨大的 JSON payload 进行 hydrate,这会随着应用增长影响其响应性。
    • 维护: 维护大型 JSON 文件很痛苦。你必须在文件之间来回跳转以插入翻译,确保没有翻译缺失且没有孤立的 key 留下。
    • 设计系统: 这会导致与设计系统不兼容(例如,LoginForm 组件),并限制在不同应用之间复制组件的能力。

    “但我们发明了 Namespaces!”

    当然,这确实是一个巨大的进步。下面对比一下在 Vite + React + React Router v7 + Intlayer 配置下主 bundle 大小的差异。我们模拟了一个 20 页的应用。

    第一个示例没有为每个 locale 进行懒加载翻译,也没有进行命名空间拆分。第二个示例则包含内容清理(purging)和翻译的动态加载。

    已优化的 bundle 未优化的 bundle
    未优化的 bundle 已优化的 bundle

    因此,多亏了 namespaces,我们将结构从以下形式迁移:

    locale/
    ├── en.json
    ├── fr.json
    └── es.json

    到这个结构:

    locale/
    ├── en/
       ├── common.json
       ├── navbar.json
       ├── footer.json
       ├── home.json
       └── about.json
    ├── fr/
       └── ...
    └── es/
        └── ...
    

    现在你必须精细地管理应用的哪些内容应该被加载,以及在何处加载它们。总之,由于复杂性,绝大多数项目都会跳过这一步(例如参见 next-i18next 指南 来了解仅仅遵循良好实践也会带来哪些挑战)。因此,这些项目最终会遇到前面解释的庞大 JSON 加载问题。

    注意,这个问题并非 i18next 所特有,而是上述所有集中式方法共有的问题。

    然而,我想提醒你,并非所有细粒度方法都能解决这个问题。例如,vue-i18n SFCinlang 的做法并不会本质上为每个语言环境按需懒加载翻译,因此你只是将捆绑包大小的问题换成了另一个问题。

    此外,如果没有适当的关注点分离,就更难将翻译内容提取并提供给译者进行审核。

    Intlayer 的按组件方法如何解决这个问题

    Intlayer 通过以下几个步骤来处理:

    1. 声明: 在代码库的任何位置使用 *.content.{ts|jsx|cjs|json|json5|...} 文件声明你的内容。这既确保了关注点分离,又保持内容与组件同处一处。内容文件可以是针对单一语言的,也可以是多语言的。
    2. Processing: Intlayer 在构建步骤中运行,用于处理 JS 逻辑、处理缺失的翻译回退、生成 TypeScript 类型、管理重复内容、从你的 CMS 获取内容,等等。
    3. Purging: 当你的应用构建时,Intlayer 会清除未使用的内容(有点像 Tailwind 管理你的类的方式),通过如下方式替换内容:

    Declaration:

    // src/MyComponent.tsx
    export const MyComponent = () => {
      const content = useIntlayer("my-key");
      return <h1>{content.title}</h1>;
    };
    // src/myComponent.content.ts
    export const {
      key: "my-key",
      content: t({
        zh: { title: "我的标题" },
        en: { title: "My title" },
        fr: { title: "Mon titre" }
      })
    }
    

    Processing: Intlayer builds the dictionary based on the .content file and generates:

    // .intlayer/dynamic_dictionary/zh/my-key.json(翻译后的 JSON 文件示例)
    {
      "key": "my-key",
      "content": { "title": "我的标题" },
    }

    替换: Intlayer 在应用构建期间转换你的组件。

    - 静态导入模式:

    // 在类 JSX 语法中的组件表示
    export const MyComponent = () => {
      const content = useDictionary({
        key: "my-key",
        content: {
          nodeType: "translation",
          translation: {
            zh: { title: "我的标题" },
            en: { title: "My title" },
            fr: { title: "Mon titre" },
          },
        },
      });
    
      return <h1>{content.title}</h1>;
    };

    - 动态导入模式:

    // 在类 JSX 语法中的组件表示
    export const MyComponent = () => {
      const content = useDictionaryAsync({
        en: () =>
          import(".intlayer/dynamic_dictionary/en/my-key.json", {
            with: { type: "json" },
          }).then((mod) => mod.default),
        // Same for other languages
      });
    
      return <h1>{content.title}</h1>;
    };
    useDictionaryAsync 使用类似 Suspense 的机制,仅在需要时加载本地化的 JSON。

    此按组件方法的主要优点:

    • 将内容声明与组件放在一起可以提高可维护性(例如:将组件移动到另一个应用或 design system。删除组件文件夹时也会同时移除相关内容,就像你通常对 .test.stories 所做的那样)

    • 以组件为单位的方法可以防止 AI 代理需要在你所有不同的文件之间来回跳转。它将所有翻译集中在一个地方,限制了任务的复杂性以及使用的 tokens 数量。

    限制

    当然,这种方法有其权衡:

    • 更难与其他 l10n 系统和额外的工具链对接。
    • 会产生锁定(lock-in)问题(这在任何 i18n 解决方案中基本都存在,因为它们有特定的语法)。

    这就是 Intlayer 试图为 i18n 提供完整工具集(100% 免费且 OSS)的原因,包括使用你自己的 AI Provider 和 API 密钥进行 AI 翻译的功能。Intlayer 还提供用于同步你的 JSON 的工具,类似于 ICU / vue-i18n / i18next 的消息格式化器,用以将内容映射到它们的特定格式。