---
createdAt: 2025-11-24
updatedAt: 2025-11-24
title: 编译器与声明式国际化的对比
description: 探讨“魔法”编译器驱动的国际化与显式声明式内容管理之间的架构权衡。
keywords:
  - Intlayer
  - 国际化
  - 博客
  - Next.js
  - JavaScript
  - React
  - i18n
  - 编译器
  - 声明式
slugs:
  - blog
  - compiler-vs-declarative-i18n
---

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

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

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

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

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

## 目录

<TOC/>

## 国际化的简史

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

大约在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 编译器文档](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/compiler.md)

## 编译器的魅力（“魔法”方法）

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

### 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 分析：** 它检查元素类型（例如，区分 `reactNode`、`label` 或 `title` 属性）。
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 请求仍然会被发起。

解决方案？采用允许声明作用域内容声明的方案，比如 `i18next`、`next-intl` 或 `intlayer`。

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

### 运行时性能开销

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

- **代价：** 如果你渲染一个包含 5,000 个项目的列表，你实际上是在为文本初始化 5,000 个 `useState` 和 `useEffect` 钩子。React 必须同时识别并重新渲染所有这 5,000 个消费者。这会导致巨大的“主线程”阻塞，在切换语言时冻结 UI。这会消耗大量内存和 CPU 周期，而声明式库（通常使用单一 Context 提供者）则能节省这些资源。

> 注意，这个问题在 React 以外的其他框架中也类似。

## 陷阱：供应商锁定

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

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

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

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

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

### 平台锁定

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

## 另一面：声明式方法的风险

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

1.  **命名空间地狱：** 你经常需要手动管理加载哪些 JSON 文件（`common.json`、`dashboard.json`、`footer.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 或成熟的库）仍然是黄金标准。它将关注点分离，保持架构清晰，并确保你的应用多语言能力不依赖于“黑盒”编译器去猜测你的意图。
