作者:
    Creation:2026-09-13Last update:2026-09-13

    Lingui VS @intlayer/lingui | 相同宏,不同运行时

    @intlayer/lingui 是针对 @lingui/core@lingui/react 的兼容适配器。你的 t`...` <Trans>useLingui()i18n._() 调用保持完全不变;宏继续正常编译;改变的是运行时消息的来源。每个调用点不再依赖每个语言环境一个编译好的全局目录,而是绑定到专门为其编译的 Intlayer 字典。

    本文在相同的 TanStack Start 应用程序上测量了这种替换,该程序分别使用纯 Lingui 和使用适配器构建了一次。这些数据来自 Benchmark Bloom。关于这两个库作为独立库的对比,请阅读 Lingui vs Intlayer。本文主要关注适配器改变了什么,以及在哪些方面它没有帮助。

    摘要 (tl;dr):在同一个 TanStack Start 应用程序中,@intlayer/lingui 在不改动宏的情况下,将平均组件体积从 85.5 KB 降至 12.8 KB gzip,水合时间从 28 ms 降至 19.7 ms,语言切换时间从 5.9 ms 降至 2.9 ms。在朴素设置(预先加载每个目录)中,它还消除了 90% 的页面泄漏 并减少了每页 12 KB。但在延迟加载设置中,它每页交付 137 KB,而纯 Lingui 为 115 KB:适配器在运行时解析 ICU,而 Lingui 交付预编译的标记数组。源语言环境泄漏(约 9-10%)在两边完全相同,因为它来自于嵌入在组件中的 message 回退,而不是来自运行时。该适配器是一个 Vite 插件;测试是在 TanStack Start 上进行的。

    什么是 @intlayer/lingui

    Lingui 由编译器和运行时组成。源码中的宏被提取到每个语言环境的 .po(或 JSON)目录中,编译为每个语言环境的 JS 模块,并通过 i18n.load() + i18n.activate() 加载到全局 I18n 实例中。每个 useLingui() 订阅该实例;每个 _() 调用都会在活动目录中查找其 ID。

    @intlayer/lingui 保留了宏和 API,并替换了目录查找逻辑:

    1. 导入别名化。 来自 @intlayer/lingui/pluginlingui() 插件包装了 vite-intlayer 并添加了 resolve.alias 条目,使得 @lingui/core@lingui/react 解析为 @intlayer/lingui。你的导入代码无需改动。
    2. 目录作为单一事实来源。 syncJSON 插件(或用于 .po 文件的 syncPO)读取你现有的目录并将其转换为 Intlayer 字典,当 CLI 或 CMS 更新时将翻译写回。通过 splitKeys: "key-prefix",带点分 ID(footer.githubhero.title)的扁平目录会拆分成每个前缀一个的小字典,而不是一个 244 KB 的庞大文件。
    3. 调用点绑定。 Intlayer 优化通道收集每个文件中传递给 _t<Trans> 的 ID,并将匹配的字典交付给组件。<Trans id="hero.title"> 独立绑定;useLingui() 绑定到该文件中使用的每个前缀。不带点分的 ID(哈希 ID、mockBanner)将回退到 Lingui 的单一 messages 字典。
    src/components/Hero.tsx
    // 你的代码,保持不变
    import { useLingui } from "@lingui/react";
    import { Trans } from "@lingui/react/macro";
    
    const Hero = () => {
      const { _ } = useLingui();
      return (
        <section>
          <h1>{_({ id: "hero.title", message: "Measure what you ship" })}</h1>
          <Trans id="hero.subtitle">Every byte counts</Trans>
        </section>
      );
    };
    
    编译器输出内容(简化版)
    import _dicHash_hero from "../.intlayer/dictionaries/hero.mjs";
    import {
      useDictionary as useLingui,
      TransDictionary as Trans,
    } from "@intlayer/lingui";
    
    const Hero = () => {
      const { _ } = useLingui(_dicHash_hero);
      return (
        <section>
          <h1>{_({ id: "hero.title", message: "Measure what you ship" })}</h1>
          <Trans id="hero.subtitle" dictionary={_dicHash_hero}>
            Every byte counts
          </Trans>
        </section>
      );
    };
    

    组件不再需要访问全局实例及其背后的整个目录。它仅访问 hero。这就是下表中组件大小列下降 7 倍的全部原因。

    适配器保留、忽略和不替换的内容

    Lingui API搭配 @intlayer/lingui
    t`...` msgpluralselect<Trans>✅ 保留。在 Intlayer 优化通道之前,在构建配置中保留 @lingui/babel-plugin-lingui-macro@lingui/swc-plugin
    useLingui(){ i18n, _, t }✅ 保留。在 Provider 外部也能工作(语言环境派生自 react-intlayer
    i18n._(id, values)i18n.t()✅ 保留。显式 ID 和哈希 ID 均可解析
    ICU 复数、selectselectordinal#✅ 保留,通过 Intlayer 的 ICU 解析器处理
    i18n.date()i18n.number()formats✅ 保留,由原生 Intl 支持
    I18nProvider✅ 保留。包装 IntlayerProvider;监听 i18n.on("change") 以确保 activate() 仍能触发重新渲染
    i18n.activate(locale)✅ 保留
    i18n.load(locale, messages) / loadAndActivate()⚠️ 作为运行时回退接受。编译后的字典优先;开发环境警告会建议移除该导入
    setupI18n({ messages, missing })⚠️ messages 作为运行时回退合并;missing 被忽略
    lingui extract / lingui compile✅ 依然是你的日常工作流程。将 syncPO / syncJSON 指向提取的目录
    I18nProvider 上的 defaultComponent⚠️ 存储在上下文中,但在渲染时未应用
    Next.js❌ 该插件包装了 vite-intlayer。仅支持 Vite、TanStack Start 和 React Router

    基准测试

    测试衡量了什么

    Benchmark Bloom 测试套件在每种设置下构建了相同的应用程序10 个页面(home, about, blog, careers, contact, FAQ, pricing, products, settings, team),10 种语言en, fr, es, de, it, pt, zh, ja, ko, ru),相同的组件和相同的内容。页面以 enfr 进行测量。

    Lingui 在四种加载策略下进行了构建,从预先导入每个编译好的目录(static)到按路由延迟导入目录(scoped-dynamic)。适配器在相同的组件上构建,仅更改了 vite.config.tsintlayer.config.ts。其 static 行打包所有语言环境;其 dynamic 行(importMode: 'dynamic')按需加载当前活动的语言环境。没有 "scoped" 变体:因为优化通道自动在每个调用点进行作用域划分。

    对于每个构建,测试套件记录:

    • Lib size:仅导入 i18n 库的空组件的 gzip 体积。
    • Page JS:每页下载的 gzip JavaScript 体积,在所有页面和语言环境中取平均值。
    • Locale leak %:下载的 JS 中属于用户查看的语言环境的翻译字符串比例。
    • Page leak %:下载的 JS 中属于用户访问的页面的翻译字符串比例。
    • Component avg:每个组件单独编译时的平均 gzip 体积。
    • E2E reactivity:选择新语言环境到 DOM 中的 html[lang] 更新之间的挂钟时间(Playwright,5 次迭代)。
    • Hydration:React 水合阶段耗时。
    以下数据来自 2026-09-12 的测试运行,使用 @lingui/react 6.6.0 和 @intlayer/lingui 9.5.1。测试应用程序刻意设计得很小(每个语言环境几十个字符串),因此泄漏百分比反映的是一种结构模式:随着内容的增长,它们会不断增加,而运行时成本保持固定。

    TanStack Start 上的测试结果

    配置策略库体积 (gz)页面 JS 平均 (gz)语言泄漏页面泄漏组件平均体积 (gz)E2E 响应性水合耗时
    base (无 i18n)-0.0 KB111.0 KB0.0%0.0%0.7 KB8.1 ms21.6 ms
    Linguistatic11.2 KB152.2 KB50.0%90.0%58.0 KB3.9 ms19.9 ms
    Linguidynamic11.2 KB115.2 KB9.3%0.0%85.5 KB5.9 ms28.0 ms
    Linguiscoped-static11.2 KB120.8 KB4.0%0.0%147.9 KB7.1 ms33.9 ms
    Linguiscoped-dynamic11.2 KB120.2 KB8.6%0.0%83.7 KB42.1 ms32.9 ms
    @intlayer/linguistatic10.3 KB140.5 KB50.0%0.0%14.9 KB3.3 ms11.3 ms
    @intlayer/linguidynamic10.3 KB137.0 KB9.9%0.0%12.8 KB2.9 ms19.7 ms
    intlayer (原生)static5.0 KB125.8 KB50.0%0.0%8.1 KB3.2 ms11.5 ms
    intlayer (原生)dynamic5.0 KB118.6 KB0.0%0.0%6.3 KB3.6 ms14.1 ms

    如何解读数据

    • 组件体积缩小 7 倍。 这是适配器的主要效果。根据策略不同,单独编译的 Lingui 组件平均体积为 58-148 KB,因为 useLingui() 会触及全局实例以及加载到其中的每个目录。使用适配器的相同组件平均只有 12.8-14.9 KB:它只触及自己的字典和 ICU 解析器,别无其他。
    • 水合快 8-14 ms。 i18n.load() + i18n.activate() 在 React 开始水合前在客户端执行;Lingui 设置越偏向懒加载,这个过程耗时越长(28-34 ms)。使用适配器时,字典作为打包器已放置在页面分块中的普通导入到达:static 下为 11.3 msdynamic 下为 19.7 ms
    • 语言切换快 2 倍,且无卡顿。 Lingui 优化后的 scoped-dynamic 设置需要 42 ms 才能更新 html[lang],因为路由目录在更改可见前必须经过获取、加载和激活。适配器在两种模式下均保持在 2.9-3.3 ms
    • 朴素设置免费获得优化。 静态 Lingui 在每个页面上打包每个目录:152.2 KB,90% 页面泄漏。静态适配器:140.5 KB,0% 页面泄漏,组件完全相同。
    • 每页字节数:Lingui 在 dynamic 中以 22 KB 胜出。 这是一个需要坦诚面对的数据。Lingui 在构建时将消息编译为标记数组,并仅附带一个遍历它们的 11 KB 运行时。适配器则包含 Intlayer 的 ICU 解析器(比原生构建多出约 15 KB 的 @intlayer/core)、适配器层(~10 KB)和 react-intlayer(~6 KB)。在此应用中,结果是 137.0 KB 对 115.2 KB。如果每页字节数是你的唯一预算考量,并且你已经在使用延迟加载的 Lingui,适配器在这方面不会带来体积缩减。
    • 两边的语言环境泄漏几乎相同。dynamic 下 Lingui 为 9.3%,适配器为 9.9%。这源于组件内部:i18n._({ id: "careers-benefits.pay", message: "Top-of-market compensation" }) 携带了英文源码作为回退,宏输出也是如此,除非显式剔除 message 字段。无论通过什么提供翻译,这部分英文都会落入 fr 分块中。原生 intlayer.content.ts,无内联源码)则为 0%。

    为什么数据会变化,以及为什么有一项保持不变

    决定这些列的有两个因素:组件绑定到了什么,以及消息以何种格式传输

    绑定方式。 在 Lingui 中,划分的基本单元是语言环境。frmessages.mjs 是一个单一模块;任何导入了加载该模块的实例的组件都可以访问其全部内容,因此打包工具无法进行比语言环境更细粒度的拆分。使用适配器时,基本单元是调用点:herofooter 是独立的导入,按组件进行拆分和延迟加载。这就是组件体积、水合和页面泄漏大幅改善的原因。

    bash
    .
    ├── lingui.config.ts
    └── src
        ├── i18n.ts                          # setupI18n(), load(), activate()
        ├── locales
       ├── en/messages.mjs              # lingui compile 输出,每种语言一个
       └── fr/messages.mjs
        └── components
            └── Hero.tsx                     # useLingui(); _("hero.title")
    
    bash
    .
    ├── intlayer.config.ts                   # syncJSON({ splitKeys: "key-prefix" })
    ├── .intlayer/                           # 生成内容:每个 ID 前缀一个字典,分语言环境
    └── src
        ├── locales
       ├── en/messages.json             # 未修改,依然是单一事实来源
       └── fr/messages.json
        └── components
            └── Hero.tsx                     # useLingui(); _("hero.title")  ← 未修改
    

    传输格式。 Lingui 的编译步骤将 {count, plural, one {# item} other {# items}} 转换为标记数组;运行时从不需要解析 ICU。适配器将消息保留为文本,并使用 Intlayer 的 ICU 解析器进行解析。这是每页固定支付一次的约 15 KB 开销,也是 dynamic 行在体积上落后而在其他所有方面领先的原因。原生 Intlayer 避免了这一点,因为 .content.ts 字典使用编译器提前解析的 enu() / insert() 节点。

    迁移只需三步

    1. 安装

      bash
      npx intlayer init --interactive
      

      该命令会检测 Lingui,读取 lingui.config.ts 以选择 syncPO.po 目录)或 syncJSON(JSON 目录),安装 intlayerreact-intlayer@intlayer/lingui 及对应的同步插件,并在 vite.config.ts 中将 @lingui/vite-plugin 替换为适配器插件。保留 @lingui/core@lingui/react 和你的宏插件:宏仍会继续编译,适配器也会复用 Lingui 的类型。

    2. 将 Intlayer 指向你的目录

      对于 JSON 目录(lingui.config.ts 中的 format: "minimal"):

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      import { syncJSON } from "@intlayer/sync-json-plugin";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH],
          defaultLocale: Locales.ENGLISH,
        },
        dictionary: {
          importMode: "dynamic",
          format: "icu",
        },
        plugins: [
          syncJSON({
            format: "icu",
            source: ({ locale, key }) => `./src/locales/${locale}/${key}.json`,
            // 按第一个分段对带点分 ID 进行分组:`footer.github` → 字典 `footer`
            splitKeys: "key-prefix",
          }),
        ],
      };
      
      export default config;
      

      对于 .po 目录,将 syncJSON 替换为来自 @intlayer/sync-po-pluginsyncPO,并使用带有 .po 扩展名的相同 source 模式。请参阅 Sync PO 插件文档

      splitKeys: "key-prefix" 是让组件体积大幅缩小的关键。目录文件保留其扁平形态;拆分仅存在于生成的字典中,写回时会自动重新拼接 ID。

    3. 添加插件

      vite.config.ts
      import { defineConfig } from "vite";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { lingui } from "@intlayer/lingui/plugin";
      
      export default defineConfig({
        plugins: [
          tanstackStart(),
          viteReact({
            // 保留你的宏插件;它必须在 Intlayer 优化通道之前运行
            babel: { plugins: ["@lingui/babel-plugin-lingui-macro"] },
          }),
          lingui(),
        ],
      });
      

      lingui() 封装了 vite-intlayer(内容监听、字典编译、优化通道),并将 @lingui/core@lingui/react 别名重定向到适配器。重新构建,上述性能收益便可立即生效。

    之后可以删除的内容

    文件 / 模式原因
    await import(`./locales/${locale}/messages.mjs`) | 字典由使用它们的组件直接导入。i18n.load() 变为回退逻辑
    i18n.load() / i18n.loadAndActivate()保留 i18n.activate(locale);移除手动加载目录的代码
    构建脚本中的 lingui compile仅当你完全迁移到 JSON / .po 作为来源并且不再导入编译后的模块时可删除

    除了字节之外的收获

    • 缺失翻译检测。 当某个语言环境缺少 ID 时,npx intlayer test 会使 CI 报错失败;而 lingui extract 仅输出统计数据。
    • npx intlayer fill 使用你选择的 AI 提供商(OpenAI、Anthropic、Mistral、Gemini 等)自动补全缺失的条目,并直接写回你的目录中。
    • 可视化编辑器和 CMS 在相同的字典上运行,非技术人员也能通过友好的图形界面轻松编辑 .po / JSON 文件。
    • 平滑迁移到 .content.ts 任何组件都可以随时从 useLingui() 切换到带有同目录内容文件的 useIntlayer("hero")。两种字典类型可以完美共存与合并。

    开始前需了解的限制

    • dynamic 模式下的每页体积开销。 如前所述:在小型应用中,相对于延迟加载的 Lingui 设置,预计每页大约增加 20 KB。这一差距不会随着内容的增加而扩大(因为它来自解析器而非目录),但也不会缩小。
    • 源语言环境泄漏仍然存在。 消息描述符和宏输出会嵌入英文源码作为回退。如果对此介意,解决方法是去除 message 字段或将该组件迁移到 .content.ts,适配器本身无法解决此问题。
    • i18n.load() 仅作为回退手段。 如果你继续导入编译好的目录并调用 load(),将会同时加载旧包与新包。请务必移除这些导入。
    • 仅支持 Vite。 @intlayer/lingui 没有提供 Next.js 插件。在 Lingui 上的 Next.js 项目应直接查阅 next-intlayer
    • defaultComponent 未生效。 如果你依赖它来自动包裹每个 <Trans>,请在组件中显式编写包装器。

    应该选择哪种方案?

    • 继续留在 Lingui:如果你已经配置好了 scoped-dynamic,你的唯一性能考量是极致的每页字节体积,且 42 ms 的语言切换和 30 ms 的水合耗时完全在应用承受范围之内。
    • 使用 @intlayer/lingui:如果你正在使用 Lingui,并希望在无需改动任何宏的前提下,获得更小的组件、更快的首屏水合与语言切换、在简单配置下实现 0% 页面泄漏、类型化 ID、CI 检查以及 AI 翻译填充。这是现有 Lingui 代码库的理想升级桥梁。
    • 迁移到原生 Intlayer(react-intlayer:当你的团队开始重构组件时。它是测试表中唯一实现 0% 语言环境泄漏、5 KB 运行时且相比基准应用仅增加 7.6 KB/页的方案。

    相关对比文章

    总结

    @intlayer/lingui 彻底改变了 Lingui 调用点的绑定机制:从绑定到全局实例及其各语言整体目录,转变为绑定到专门为该组件编译的独立字典。在相同的 TanStack Start 应用中,无需编辑任何宏即可实现 组件体积缩小 7 倍水合加快 8-14 ms语言切换提速 2 倍且消除了 42 ms 的性能骤降。它不会修改组件内置的回退内容(因此保留了源语言泄漏),并在运行时解析 ICU(使得动态加载模式下每页比纯 Lingui 多出约 20 KB)。在选型前,请务必明确团队的核心性能预算。

    所有原始测试数据、测试应用和评测脚本均可在 Benchmark Bloom 代码仓库 中找到。欢迎自行复现并验证。

    详情请参考 “为什么选择 Intlayer?”文档

    评论

    暂无评论。成为第一个分享您想法的人吧。

    相关文章

    最新文章