--- createdAt: 2026-09-16 updatedAt: 2026-09-16 title: "如何在 2026 年选择合适的 Vue i18n 国际化库" description: Vue 与 Nuxt 国际化选型决策指南。在对比 vue-i18n、@nuxtjs/i18n、fluent-vue、Paraglide 与 Intlayer 之前需要明确的关键问题,以及各方案在打包体积(bundle size)、类型支持(typing)与 SSR 负载方面的成本权衡。 keywords: - vue i18n - vue internationalization - vue-i18n - nuxt i18n - fluent-vue - Paraglide - Intlayer - i18n library comparison slugs: - blog - how-to-pick-vue-i18n-library author: aymericzip --- # 如何选择合适的 Vue i18n 库 "Vue i18n" 既是一个通用术语,也是几乎所有人都会安装的库的名称。这既方便又容易产生误解:`vue-i18n` 是一个不错的默认选择,但它并非唯一选项。而在执行 `npm install` 之前,往往很少有人会先思考那些决定技术选型的关键问题(是否使用 SSR、页面数量有多少、谁来编写翻译)。 本指南将首先梳理这些问题,然后将答案映射到适用于原生 Vite + Vue 以及 Nuxt 的库。 ![Vue i18n 库生态系统](https://github.com/aymericzip/intlayer/blob/main/docs/assets/cloud_i18n_logo.webp?raw=true) ## 目录 ## 对比各库之前需要回答的 6 个问题 1. **Vite SPA 还是 Nuxt?** 在 SPA 中,语言包的成本主要是 JS bundle 体积问题。在 Nuxt 中,它还会变成 HTML payload 问题,因为翻译文本会被序列化到 SSR state 中并在客户端进行 hydration。绝大多数关于“vue-i18n 运行缓慢”的反馈都源于 Nuxt 应用的这一机制。 2. **谁来编写翻译?** 开发者、TMS、交付 ICU 字符串的翻译机构,还是 AI pipeline。`vue-i18n` 使用其特有的管道符分隔复数语法,而非标准 ICU 格式。如果文案来自外部,这一点非常关键。 3. **有多少个 locale 和页面?** 2 个 locale 和 5 个页面可以直接打包所有内容。10 个 locale 和 40 个路由则不可行,此时加载策略会成为主要的性能瓶颈。 4. **翻译 key 是否需要类型检查?** 在 `vue-i18n` 中,除非传入 message schema 泛型,否则 `t("cart.totl")` 仍然可以通过编译,而该 schema 往往又会与懒加载语言包产生冲突。 5. **内容包含什么?** 仅包含 UI 标签,还是包含 markdown、句中链接以及针对特定 locale 的组件。当面对富文本内容时,返回纯字符串的 `t()` 会显得力不从心。 6. **CSP 是否是硬性约束?** 默认的 `vue-i18n` 构建会在浏览器中使用 `new Function` 编译 message。纯 runtime 构建需要配合 `@intlify/unplugin-vue-i18n` 在 build time 进行预编译。 记录下这些问题的答案。下文的所有分析都将围绕它们展开。 ## 整体格局一览 Vue 生态中的 i18n 库比 React 更少,且它们源于不同的架构发展浪潮。 ![JavaScript i18n 库发展历程](https://github.com/aymericzip/intlayer/blob/main/docs/assets/history_i18n.webp?raw=true) `vue-i18n` 出现于 2015 年,自此成为默认选择。`@nuxt/i18n` 对其进行了封装,提供了 locale 路由、SEO 标签以及按 locale 懒加载功能。Message 会被编译为 render function,如果配置了 unplugin 则在 build time 编译,否则在浏览器运行时编译。 Mozilla Fluent 的 `.ftl` 文件带来了更友好的 message 语法,并支持感知语法的多变体处理。但它不支持 key 的类型推导,且 Vite 插件会将所有 locale 全部打包进每个页面中。 Paraglide 为每个 message 生成一个独立函数,并交由打包工具(bundler)进行 tree-shaking。Intlayer 则在 `.content.ts` 文件中针对每个 component 声明内容,自动生成类型定义,并仅按需传输当前路由渲染所需的内容。 [JavaScript i18n 发展史](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/history_of_i18n.md)详细介绍了各个发展阶段。 ## 最关键的决策:内容存放在哪里以及何时加载 两种结构性选择决定了不同方案之间大部分的 bundle 体积差异: - **集中式还是局部作用域(scoped)内容。** 整个应用共用一个 `locales/en.json`,还是每个 component 各自声明。 - **静态导入还是动态导入。** 启动时全量加载,还是按需获取当前激活的 locale(理想情况下还包括当前路由)。 下图估算了一个包含 1 到 10 个页面、翻译为 1 到 10 种语言、每页约 30 KB 文本的理论应用的 payload 情况。 ![不同架构下的理论内容泄漏情况](https://github.com/aymericzip/intlayer/blob/main/docs/assets/theorical_content_leakage.webp?raw=true) `vue-i18n` 支持动态加载维度:在 `import()` 后调用 `setLocaleMessage` 意味着不再需要加载用户不阅读的其他 9 种语言。但它无法做到按页面维度拆分。一个 locale 的 catalog 是一个完整的对象,加载它会同时载入每个页面的文案。在 SPA 中可能不易察觉,但在使用 `@nuxtjs/i18n` 且页面超过 10 个的 Nuxt 应用中,每个路由都会重复携带所有其他路由的文本两次:一次在 JS chunk 中,另一次在 SSR payload 中。 [Vue 基准测试](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/benchmark/vue.md)将其衡量为“其他路由泄漏”和“其他 locale 泄漏”。如果对第 3 个问题的回答是“页面很多”,那么这一部分的考量将重于任何 API 偏好。[组件级对比集中式 i18n](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/per-component_vs_centralized_i18n.md)一文讨论了同一权衡在维护层面的影响。 ## 候选库对比 库体积数据来自 [Vue 基准测试](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/benchmark/vue.md):在包含 10 个页面、10 种语言的应用中,空 component 中引入 plugin 加上 composable,在打包、tree-shaking 和代码压缩后的体积。内容大小单独计算。 | 库 | 内容模型 | 类型安全 | Message 格式 | 按路由代码拆分 | 库体积 | | :------------- | :-------------------------------------------------- | :----------------------------- | :---------------------------------- | :-------------------- | :--------------------------------- | | `vue-i18n` | 每个 locale 集中式 catalog,可选 SFC `` block | 2/5 — 通过 schema 泛型手动启用 | 自定义(管道符复数) | 否 | ~24.3 kB | | `@nuxtjs/i18n` | 与 `vue-i18n` 相同,外加路由与 SEO 标签支持 | 2/5 — 相同 | 相同 | 否,仅按 locale 拆分 | ~24.3 kB | | `fluent-vue` | `.ftl` 文件(Mozilla Fluent) | 1/5 — 无 | Fluent | 否 | ~29.7 kB | | Paraglide | inlang 项目,自动生成函数 | 3.5/5 — 自动生成 | 自定义 | 通过 tree-shaking | 接近于 0(因为代码生成到代码库中) | | Intlayer | 每个 component 一个 `.content.ts` | 5/5 — 自动生成,默认开启 | Intlayer (+ ICU, i18next, vue-i18n) | 是,按 component 拆分 | ~3.9 kB | > 数据仅代表基准测试当时版本的快照。在仅凭体积做决定之前,建议在自己的应用中进行测试。 > 类型安全:5/5 表示键、参数和每个语言环境均无需手动配置即可得到校验,包括 URL 格式化工具与辅助函数。 Paraglide 接近于零的运行时体积源于其架构设计:运行时代码直接生成到你的代码库中,这意味着每次 push 前都需要重新生成,并且生成的文件容易引发 merge conflict。Intlayer 需要 `vite-intlayer`(或 Nuxt 模块)支持,因此必须依赖构建步骤。 ## 将需求与库进行匹配 使用 Composition 模式(`legacy: false`)的 `vue-i18n`,配合 `@intlify/unplugin-vue-i18n` 仅引入 runtime-only build。使用 `import()` 懒加载 locale。这能满足大多数小型应用的需求,社区解决方案也随处可见。SFC 的 `` block 能将 message 与 component 放在一起,这很有帮助,但围绕它们的提取和 TMS 工具链没有 JSON catalog 那么完善,因此团队应尽早确定使用哪种方式。 `@nuxtjs/i18n` 开箱即用地提供了路由策略、`hreflang` 标签和 locale 检测功能,单凭这一点就足以让它成为页面较少的内容类网站的理想之选。它的限制在于按 locale 管理的 catalog:超过 10 个页面后,SSR payload 就会携带所有页面的文案。如果属于这种情况,要么手动为 `vue-i18n` 配置按路由拆分 message,要么转向局部作用域内容方案。[Nuxt i18n 文章](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/list_i18n_technologies/frameworks/nuxt.md)首先详细介绍了路由策略的选择。 `vue-i18n` 的复数语法(`"no item | one item | {count} items"`)并非 ICU 格式,无法通用。需要特别告知翻译人员,且 TMS 导出的内容也不会生成该格式。要么在建立第一个 catalog 之前统一格式,要么选择格式与供应商兼容的库。Intlayer 目前对 ICU 仅提供部分支持,因此如果现在接收的是 ICU 字符串,这一点也需要重点考虑。 优先选择在 build time 编译的局部作用域内容方案。Paraglide 通过 tree-shaking 达成这一目标,在 Vite 上表现符合预期。Intlayer 则通过按 component 声明来实现,仅传输当前路由渲染所需的内容。在 `vue-i18n` 中,虽然可以手动按路由拆分 message,但没有强制约束机制,一旦某个公共 component 引入了全局命名空间,就会在无形中破坏这种拆分。 `vue-i18n` 可以通过向 `createI18n` 传递 schema 泛型来实现类型推导。虽然可行,但一旦使用懒加载 catalog 就会失效,因为 schema 描述的 message 当时可能尚未加载。如果不希望手动维护这些,请选择能够根据内容自动生成类型的库:Paraglide 或 Intlayer。[检测缺失翻译](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/detecting_missing_translations.md)一文对比了各方案在 build time 能捕获的问题。 Markdown 页面、中间带有 `` 的句子、按 locale 定制的 component。`vue-i18n` 提供了用于 component 插值的 ``,功能可用但较为繁琐。Intlayer 的 content node 可以直接支持 markdown、HTML 和嵌套 object,更适合内容密集型应用。 在这种情况下,集中式 JSON 失去了存在的必要。就近放置内容加上自动补全缺失 locale 的 CLI 是更高效的路径。Intlayer 的 `fill` 命令可以使用你自己的 API key(OpenAI、Anthropic、Mistral、Gemini)运行,并且只对发生变更的内容进行增量翻译。 ## 各库的不足之处 - **`vue-i18n`**:体积最大,采用自定义复数格式,类型支持需要手动配置且在懒加载时较脆弱,不支持按路由作用域划分,未使用的废弃 key 会默默堆积。在 Vue 3 应用中保留 `legacy: true` 会保留 Vue 2 兼容层,并失去 `useI18n()` 的类型推导支持。 - **`@nuxtjs/i18n`**:继承了上述所有缺点,且一旦路由超过十几个,SSR payload 就会携带所有页面的字符串。 - **`fluent-vue`**:Message 语法优秀,但缺乏 key 类型检查,且 Vite 插件会将所有语言的所有内容打包进每个页面中。基准测试中体积最大。 - **Paraglide**:生成的文件需要提交到 Git 仓库,每次 push 前都要重新生成,且每次调用 message 时都是从 cookie 或 storage 读取 locale,而非响应式 store,在切换 locale 时会带来额外开销。 - **Intlayer**:必须依赖构建插件,生态相对较小,对 ICU 仅部分支持,且内容在设计上分散在整个 codebase 中,因此为翻译人员导出单个 JSON 文件需要额外工具支持。 ## 各方案的代码实现示例 下面是用各候选库编写的同一个 component 示例:包含标题和复数计数的购物车摘要。值得关注的不是 template 本身,而是内容存放在哪里以及 `vue-tsc` 对其类型了解多少。 ```json fileName="src/locales/en.json" { "cart": { "title": "Your cart", "items": "no item | one item | {count} items" } } ``` ```json fileName="src/locales/fr.json" { "cart": { "title": "Votre panier", "items": "aucun article | un article | {count} articles" } } ``` ```json fileName="src/locales/es.json" { "cart": { "title": "Tu carrito", "items": "ningún artículo | un artículo | {count} artículos" } } ``` ```vue fileName="src/components/CartSummary.vue" ``` 使用管道符分隔的复数是 vue-i18n 特有的格式,并非 ICU。除非向 `createI18n` 传递 message schema 泛型,否则 `t` 可以接受任意字符串。 ```ftl fileName="src/locales/en.ftl" cart-title = Your cart cart-items = { $count -> [one] { $count } item *[other] { $count } items } ``` ```ftl fileName="src/locales/fr.ftl" cart-title = Votre panier cart-items = { $count -> [one] { $count } article *[other] { $count } articles } ``` ```ftl fileName="src/locales/es.ftl" cart-title = Tu carrito cart-items = { $count -> [one] { $count } artículo *[other] { $count } artículos } ``` ```vue fileName="src/components/CartSummary.vue" ``` Fluent 的语法能很好地处理复数和语法变体。Message id 是无类型的字符串,且 Vite 插件会将所有 locale 打包进每个页面。 ```json fileName="messages/en.json" { "cart_title": "Your cart", "cart_items": "{count} items" } ``` ```json fileName="messages/fr.json" { "cart_title": "Votre panier", "cart_items": "{count} articles" } ``` ```json fileName="messages/es.json" { "cart_title": "Tu carrito", "cart_items": "{count} artículos" } ``` ```vue fileName="src/components/CartSummary.vue" ``` 每个 message 都是生成的类型化函数,因此遗漏 key 会直接报 import 错误。`paraglide/` 目录会生成到仓库中,并在每次修改时重新生成。 ```ts fileName="src/components/cartSummary.content.ts" import { plural, t, type Dictionary } from "intlayer"; const cartSummaryContent = { key: "cart-summary", content: { title: t({ zh: "你的购物车", en: "Your cart", fr: "Votre panier", es: "Tu carrito", }), items: t({ zh: plural({ other: "{{count}} 件商品" }), en: plural({ one: "{{count}} item", other: "{{count}} items" }), fr: plural({ one: "{{count}} article", other: "{{count}} articles" }), es: plural({ one: "{{count}} artículo", other: "{{count}} artículos" }), }), }, } satisfies Dictionary; export default cartSummaryContent; ``` ```vue fileName="src/components/CartSummary.vue"