接收有关即将发布的Intlayer的通知
    Creation:2025-11-24Last update:2025-11-24

    支持与反对编译器驱动国际化的观点

    如果你从事网页应用开发超过十年,你会知道国际化(i18n)一直是一个难点。它通常是没人愿意做的任务——提取字符串、管理 JSON 文件,以及处理复数规则。

    最近,一波新的“基于编译器”的国际化(i18n)工具涌现,承诺让这份痛苦消失。它们的宣传非常诱人:只需在组件中编写文本,构建工具会处理剩下的一切。无需键名,无需导入,纯粹是魔法。

    但正如软件工程中的所有抽象一样,魔法是有代价的。

    在这篇博客文章中,我们将探讨从声明式库向基于编译器方法的转变,它们引入的隐藏架构债务,以及为什么"无聊"的方式可能仍然是专业应用的最佳选择。

    目录

    国际化的简史

    要理解我们现在所处的位置,必须回顾我们从哪里开始。

    大约在2011年至2012年,JavaScript的生态环境截然不同。我们今天所熟知的打包工具(如Webpack、Vite)还不存在,或者还处于初期阶段。那时,我们是在浏览器中手动拼接脚本。在那个时代,像i18next这样的库诞生了。

    它们以当时唯一可行的方式解决了问题:运行时字典。你将一个庞大的JSON对象加载到内存中,然后通过函数动态查找键。这种方式可靠、明确,并且在任何地方都能工作。

    快进到今天。我们拥有强大的编译器(如SWC、基于Rust的打包工具),它们可以在毫秒级别解析抽象语法树(AST)。这种能力催生了一个新想法:为什么我们还要手动管理键?为什么编译器不能直接识别文本“Hello World”,并替我们替换它?

    于是,基于编译器的国际化(i18n)诞生了。

    基于编译器的 i18n 示例:

    • Paraglide(经过 Tree-shaking 的模块,将每条消息编译成一个小型的 ESM 函数,使打包工具能够自动剔除未使用的语言和键。你导入消息作为函数,而不是通过字符串键查找。)
    • LinguiJS(宏到函数的编译器,在构建时将类似 <Trans> 的消息宏重写为普通的 JS 函数调用。你可以使用 ICU/MessageFormat 语法,且运行时开销非常小。)
    • Lingo.dev(专注于自动化本地化流程,在构建 React 应用时直接注入翻译内容。它可以使用 AI 自动生成翻译,并直接集成到 CI/CD 流程中。)
    • Wuchale(以 Svelte 为先的预处理器,提取 .svelte 文件中的内联文本并将其编译为零包装的翻译函数。它避免使用字符串键,并将内容提取逻辑完全与主应用运行时分离。)
    • Intlayer(编译器 / 提取 CLI,解析你的组件,生成类型化字典,并可选择性地重写代码以使用显式的 Intlayer 内容。目标是在保持声明式、框架无关核心的同时,利用编译器提升开发效率。)

    声明式 i18n 示例:

    • i18next / react-i18next / next-i18next(成熟的行业标准,使用运行时 JSON 字典和丰富的插件生态系统)
    • react-intl(FormatJS 库的一部分,专注于标准 ICU 消息语法和严格的数据格式化)
    • next-intl(专为 Next.js 优化,集成了 App Router 和 React Server Components)
    • vue-i18n / @nuxt/i18n(标准的 Vue 生态系统解决方案,提供组件级翻译块和紧密的响应式集成)
    • svelte-i18n(围绕 Svelte stores 的轻量级封装,用于响应式运行时翻译)
    • angular-translate(传统的动态翻译库,依赖运行时键查找而非构建时合并)
    • angular-i18n(Angular 原生的预编译方法,在构建时将 XLIFF 文件直接合并到模板中)
    • Tolgee(结合声明式代码和上下文 SDK,实现 UI 中的“点击翻译”编辑)
    • Intlayer(基于每个组件的方法,使用内容声明文件,实现原生的 tree-shaking 和 TypeScript 校验)

    Intlayer 编译器

    虽然 Intlayer 本质上是一种鼓励对内容采取 声明式方法 的解决方案,但它包含了一个编译器,以帮助加快开发速度或促进快速原型设计。

    Intlayer 编译器会遍历你的 React、Vue 或 Svelte 组件的 AST(抽象语法树),以及其他 JavaScript/TypeScript 文件。它的作用是检测硬编码字符串,并将它们提取到专门的 .content 声明中。

    更多详情,请查阅文档:Intlayer 编译器文档

    编译器的魅力(“魔法”方法)

    这种新方法之所以流行是有原因的。对于开发者来说,这种体验令人难以置信。

    1. 速度与“流畅感”

    当你进入状态时,停下来思考一个语义化的变量名(如 home_hero_title_v2)会打断你的思路。使用编译器方法,你只需输入 <p>Welcome back</p>,然后继续前进。摩擦为零。

    2. 旧代码救援任务

    想象一下,继承了一个拥有5000个组件且没有任何翻译的大型代码库。用手动基于键的系统来改造它,将是一场持续数月的噩梦。基于编译器的工具则作为一种救援策略,能够即时提取成千上万的字符串,而你无需手动触碰任何文件。

    3. AI 时代

    这是一个我们不应忽视的现代优势。AI 编码助手(如 Copilot 或 ChatGPT)自然生成标准的 JSX/HTML。它们并不知道你特定的翻译键模式。

    • 声明式(Declarative): 你必须重写 AI 的输出,将文本替换为键。
    • 编译器(Compiler): 你只需复制粘贴 AI 的代码,它就能直接工作。

    现实检验:为什么“魔法”是危险的

    虽然“魔法”很吸引人,但抽象层会泄露。依赖构建工具来理解人类意图会引入架构上的脆弱性。

    启发式脆弱性(猜测游戏)

    编译器必须猜测什么是内容,什么是代码。这会导致一些边缘情况,最终你会发现自己在“与工具斗争”。

    考虑以下场景:

    • <span className="active"></span> 会被提取吗?(它是字符串,但很可能是类名)。
    • <span status="pending"></span> 会被提取吗?(它是一个属性值)。
    • <span>{"Hello World"}</span> 会被提取吗?(它是一个 JS 表达式)。
    • <span>Hello {name}. How are you?</span> 会被提取吗?(插值很复杂)。
    • <span aria-label="Image of cat"></span> 会被提取吗?(无障碍属性需要翻译)。
    • <span data-testid="my-element"></span> 会被提取吗?(测试 ID 不应被翻译)。
    • <MyComponent errorMessage="An error occurred" /> 会被提取吗?
    • <p>This is a paragraph{" "}\n containing multiple lines</p> 会被提取吗?
    • <p>{getStatusMessage()}</p> 函数结果会被提取吗?
    • <div>{isLoading ? "The page is loading" : <MyComponent/>} </div> 会被提取吗?
    • <span>AX-99</span> 这样的产品 ID 会被提取吗?

    你不可避免地会添加特定注释(如 // ignore-translation,或特定属性如 data-compiler-ignore="true")来防止破坏你的应用逻辑。

    Intlayer 如何处理这种复杂性?

    Intlayer 使用混合方法来检测字段是否应被提取用于翻译,旨在尽量减少误报:

    1. AST 分析: 它检查元素类型(例如,区分 reactNodelabeltitle 属性)。
    2. 模式识别: 它检测字符串是否首字母大写或包含空格,这表明它更可能是人类可读的文本,而非代码标识符。

    动态数据的硬性限制

    编译器提取依赖于静态分析。它必须在代码中看到字面字符串,才能生成稳定的 ID。 如果您的 API 返回一个错误代码字符串,比如 server_error,您无法通过编译器进行翻译,因为编译器在构建时并不知道该字符串的存在。您被迫为动态数据构建一个二级的“仅运行时”系统。

    缺乏分块

    某些编译器不会按页面对翻译内容进行分块。如果您的编译器为每种语言生成一个大型 JSON 文件(例如 ./lang/en.json./lang/fr.json 等),那么访问单个页面时,您很可能会加载所有页面的内容。此外,每个使用这些内容的组件可能会被注入远多于所需的内容,可能导致性能问题。

    还要注意动态加载翻译内容。如果不这样做,你将会加载当前语言之外的所有语言内容。

    为了说明这个问题,考虑一个有10个页面和10种语言(全部100%独特)的站点。你将会加载另外99个页面的内容(10 × 10 - 1)。

    “Chunk爆炸”和网络瀑布效应

    为了解决chunking问题,一些解决方案提供了按组件甚至按键的chunking。然而,这个问题只是部分解决了。这些解决方案的卖点通常是说“你的内容会被tree-shake”。

    实际上,如果你静态加载内容,你的解决方案会tree-shake未使用的内容,但你仍然会加载所有语言的内容到你的应用中。

    那么为什么不动态加载呢?是的,在这种情况下你会加载比必要内容更多的内容,但这并非没有权衡。

    动态加载内容会将每一块内容隔离到它自己的代码块中,只有在组件渲染时才会加载。这意味着你每个文本块都会发起一次 HTTP 请求。页面上有 1000 个文本块?→ 你将向服务器发起 1000 次 HTTP 请求。为了限制影响并优化应用的首次渲染时间,你需要插入多个 Suspense 边界或骨架加载器(Skeleton Loaders)。

    注意:即使使用 Next.js 和 SSR,组件在加载后仍会被水合(hydrated),因此 HTTP 请求仍然会被发起。

    解决方案?采用允许声明作用域内容声明的方案,比如 i18nextnext-intlintlayer

    注意:i18nextnext-intl 需要你为每个页面手动管理命名空间 / 消息导入,以优化你的包大小。你应该使用类似 rollup-plugin-visualizer(vite)、@next/bundle-analyzer(next.js)或 webpack-bundle-analyzer(React CRA / Angular / 等)这样的包分析工具来检测是否有未使用的翻译污染了你的包。

    运行时性能开销

    为了使翻译具有响应性(以便切换语言时能即时更新),编译器通常会向每个组件注入状态管理钩子。

    • 代价: 如果你渲染一个包含 5,000 个项目的列表,你实际上是在为文本初始化 5,000 个 useStateuseEffect 钩子。React 必须同时识别并重新渲染所有这 5,000 个消费者。这会导致巨大的“主线程”阻塞,在切换语言时冻结 UI。这会消耗大量内存和 CPU 周期,而声明式库(通常使用单一 Context 提供者)则能节省这些资源。
    注意,这个问题在 React 以外的其他框架中也类似。

    陷阱:供应商锁定

    选择 i18n 解决方案时要小心,确保它允许提取或迁移翻译键。

    在声明式库的情况下,您的源代码明确包含了您的翻译意图:这些就是您的键,您可以控制它们。如果您想更换库,通常只需要更新导入即可。

    而采用编译器方法时,您的源代码可能只是纯英文文本,没有任何翻译逻辑的痕迹:所有内容都隐藏在构建工具配置中。如果该插件不再维护或您想更换解决方案,您可能会陷入困境。没有简单的“弹出”方式:您的代码中没有可用的键,您可能需要为新库重新生成所有翻译。

    一些解决方案还提供翻译生成服务。没有更多积分了?就没有更多翻译了。

    编译器通常会对文本进行哈希处理(例如,"Hello World" -> x7f2a)。你的翻译文件看起来像 { "x7f2a": "Hola Mundo" }。陷阱在于:如果你更换了库,新库会看到 "Hello World" 并查找该键。但它找不到,因为你的翻译文件充满了哈希值(x7f2a)。

    平台锁定

    通过选择基于编译器的方法,你会将自己锁定在底层平台上。例如,某些编译器并非适用于所有打包工具(如 Vite、Turbopack 或 Metro)。这可能会使未来的迁移变得困难,你可能需要采用多种解决方案来覆盖所有应用程序。

    另一面:声明式方法的风险

    公平地说,传统的声明式方法也并不完美。它有自己的一些“坑”。

    1. 命名空间地狱: 你经常需要手动管理加载哪些 JSON 文件(common.jsondashboard.jsonfooter.json)。如果你忘记加载其中一个,用户就会看到原始的键名。
    2. 过度获取(Over-fetching): 如果配置不当,很容易在初始加载时意外加载所有页面的所有翻译键,导致包体积膨胀。
    3. 同步漂移(Sync Drift): 通常情况下,某些键会在使用它们的组件被删除后仍留在 JSON 文件中。你的翻译文件会无限增长,充满“僵尸键”。

    Intlayer 的折中方案

    这正是像 Intlayer 这样的工具试图创新的地方。Intlayer 理解虽然编译器功能强大,但隐式魔法是危险的。

    Intlayer 提供了一种混合方法,让你能够同时享受两种方法的优势:声明式内容管理,同时兼容其编译器以节省开发时间。

    即使你不使用 Intlayer 编译器,Intlayer 也提供了一个 transform 命令(也可以通过 VSCode 扩展访问)。它不仅仅是在隐藏的构建步骤中做魔法,而是可以实际重写你的组件代码。它会扫描你的文本,并将其替换为代码库中显式的内容声明。

    这让你同时拥有两者的优点:

    1. 细粒度控制: 你可以将翻译内容保持在组件附近(提升模块化和 tree-shaking 效果)。
    2. 安全性: 翻译变成显式代码,而不是隐藏的构建时魔法。
    3. 无锁定: 由于代码被转换成你仓库中的声明式结构,你可以轻松按下 Tab 键,或使用 IDE 的 copilot 来生成内容声明,而不是将逻辑隐藏在 webpack 插件中。

    结论

    那么,你应该选择哪种方式呢?

    如果你正在构建 MVP,或者想快速推进:
    基于编译器的方法是一个有效的选择。它允许你非常快速地开发。你不需要担心文件结构或键值。你只需构建即可。技术债务是“未来的你”需要解决的问题。

    如果你是初级开发者,或者不在意优化:
    如果你想减少手动管理,基于编译器的方法可能是最好的。你不需要自己处理键或翻译文件——只需编写文本,编译器会自动完成剩下的工作。这减少了设置工作量和与手动步骤相关的常见国际化错误。

    如果你正在对一个已有数千个组件需要重构的现有项目进行国际化:
    基于编译器的方法在这里可以是一个务实的选择。初始的提取阶段可以节省数周甚至数月的手动工作。然而,建议考虑使用像 Intlayer 的 transform 命令这样的工具,它可以提取字符串并将其转换为显式的声明式内容声明。这让你既能享受自动化的速度,又能保持声明式方法的安全性和可移植性。你可以两者兼得:快速的初始迁移而不会带来长期的架构债务。

    如果你正在构建一个专业的企业级应用程序: 魔法通常不是一个好主意。你需要控制。

    • 你需要处理来自后端的动态数据。
    • 你需要确保在低端设备上的性能(避免钩子爆炸)。 /// 你需要确保不会永远被锁定在特定的构建工具中。

    对于专业应用,声明式内容管理(如 Intlayer 或成熟的库)仍然是黄金标准。它将关注点分离,保持架构清晰,并确保你的应用多语言能力不依赖于“黑盒”编译器去猜测你的意图。