@intlayer/docs 6.1.4 → 6.1.6-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/blog/ar/next-i18next_vs_next-intl_vs_intlayer.md +1366 -75
- package/blog/ar/nextjs-multilingual-seo-comparison.md +364 -0
- package/blog/de/next-i18next_vs_next-intl_vs_intlayer.md +1288 -72
- package/blog/de/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/en/intlayer_with_next-i18next.mdx +431 -0
- package/blog/en/intlayer_with_next-intl.mdx +335 -0
- package/blog/en/next-i18next_vs_next-intl_vs_intlayer.md +583 -336
- package/blog/en/nextjs-multilingual-seo-comparison.md +360 -0
- package/blog/en-GB/next-i18next_vs_next-intl_vs_intlayer.md +1144 -37
- package/blog/en-GB/nextjs-multilingual-seo-comparison.md +360 -0
- package/blog/es/next-i18next_vs_next-intl_vs_intlayer.md +1236 -64
- package/blog/es/nextjs-multilingual-seo-comparison.md +363 -0
- package/blog/fr/next-i18next_vs_next-intl_vs_intlayer.md +1142 -75
- package/blog/fr/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/hi/nextjs-multilingual-seo-comparison.md +363 -0
- package/blog/it/next-i18next_vs_next-intl_vs_intlayer.md +1130 -55
- package/blog/it/nextjs-multilingual-seo-comparison.md +363 -0
- package/blog/ja/next-i18next_vs_next-intl_vs_intlayer.md +1150 -76
- package/blog/ja/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/ko/next-i18next_vs_next-intl_vs_intlayer.md +1139 -73
- package/blog/ko/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/pt/next-i18next_vs_next-intl_vs_intlayer.md +1143 -76
- package/blog/pt/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/ru/next-i18next_vs_next-intl_vs_intlayer.md +1150 -74
- package/blog/ru/nextjs-multilingual-seo-comparison.md +370 -0
- package/blog/tr/next-i18next_vs_next-intl_vs_intlayer.md +2 -0
- package/blog/tr/nextjs-multilingual-seo-comparison.md +362 -0
- package/blog/zh/next-i18next_vs_next-intl_vs_intlayer.md +1152 -75
- package/blog/zh/nextjs-multilingual-seo-comparison.md +394 -0
- package/dist/cjs/generated/blog.entry.cjs +16 -0
- package/dist/cjs/generated/blog.entry.cjs.map +1 -1
- package/dist/cjs/generated/docs.entry.cjs +16 -0
- package/dist/cjs/generated/docs.entry.cjs.map +1 -1
- package/dist/esm/generated/blog.entry.mjs +16 -0
- package/dist/esm/generated/blog.entry.mjs.map +1 -1
- package/dist/esm/generated/docs.entry.mjs +16 -0
- package/dist/esm/generated/docs.entry.mjs.map +1 -1
- package/dist/types/generated/blog.entry.d.ts +1 -0
- package/dist/types/generated/blog.entry.d.ts.map +1 -1
- package/dist/types/generated/docs.entry.d.ts +1 -0
- package/dist/types/generated/docs.entry.d.ts.map +1 -1
- package/docs/ar/component_i18n.md +186 -0
- package/docs/ar/vs_code_extension.md +48 -109
- package/docs/de/component_i18n.md +186 -0
- package/docs/de/vs_code_extension.md +46 -107
- package/docs/en/component_i18n.md +186 -0
- package/docs/en/interest_of_intlayer.md +2 -2
- package/docs/en/intlayer_with_nextjs_14.md +18 -1
- package/docs/en/intlayer_with_nextjs_15.md +18 -1
- package/docs/en/vs_code_extension.md +24 -114
- package/docs/en-GB/component_i18n.md +186 -0
- package/docs/en-GB/vs_code_extension.md +42 -103
- package/docs/es/component_i18n.md +182 -0
- package/docs/es/vs_code_extension.md +53 -114
- package/docs/fr/component_i18n.md +186 -0
- package/docs/fr/vs_code_extension.md +50 -111
- package/docs/hi/component_i18n.md +186 -0
- package/docs/hi/vs_code_extension.md +49 -110
- package/docs/it/component_i18n.md +186 -0
- package/docs/it/vs_code_extension.md +50 -111
- package/docs/ja/component_i18n.md +186 -0
- package/docs/ja/vs_code_extension.md +50 -111
- package/docs/ko/component_i18n.md +186 -0
- package/docs/ko/vs_code_extension.md +48 -109
- package/docs/pt/component_i18n.md +186 -0
- package/docs/pt/vs_code_extension.md +46 -107
- package/docs/ru/component_i18n.md +186 -0
- package/docs/ru/vs_code_extension.md +48 -109
- package/docs/tr/component_i18n.md +186 -0
- package/docs/tr/vs_code_extension.md +54 -115
- package/docs/zh/component_i18n.md +186 -0
- package/docs/zh/vs_code_extension.md +51 -105
- package/package.json +11 -11
- package/src/generated/blog.entry.ts +16 -0
- package/src/generated/docs.entry.ts +16 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
createdAt: 2025-08-23
|
|
3
|
-
updatedAt: 2025-
|
|
3
|
+
updatedAt: 2025-09-29
|
|
4
4
|
title: next-i18next vs next-intl vs Intlayer
|
|
5
5
|
description: 比较 next-i18next、next-intl 和 Intlayer 在 Next.js 应用国际化(i18n)中的表现
|
|
6
6
|
keywords:
|
|
@@ -17,10 +17,15 @@ slugs:
|
|
|
17
17
|
- next-i18next-vs-next-intl-vs-intlayer
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
# next-i18next VS next-intl VS intlayer | Next.js
|
|
20
|
+
# next-i18next VS next-intl VS intlayer | Next.js 国际化 (i18n)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
让我们来看看 Next.js 的三种 i18n 方案:next-i18next、next-intl 和 Intlayer 之间的相似点和差异。
|
|
25
|
+
|
|
26
|
+
这不是一个完整的教程,而是一个帮助你选择的比较。
|
|
27
|
+
|
|
28
|
+
我们重点关注 **Next.js 13+ App Router**(带有 **React 服务器组件**)并评估:
|
|
24
29
|
|
|
25
30
|
1. **架构与内容组织**
|
|
26
31
|
2. **TypeScript 与安全性**
|
|
@@ -28,135 +33,1207 @@ slugs:
|
|
|
28
33
|
4. **路由与中间件**
|
|
29
34
|
5. **性能与加载行为**
|
|
30
35
|
6. **开发者体验(DX)、工具链与维护**
|
|
31
|
-
7. **SEO
|
|
36
|
+
7. **SEO 与大型项目的可扩展性**
|
|
32
37
|
|
|
33
|
-
> **简而言之**:这三者都能实现 Next.js
|
|
38
|
+
> **简而言之**:这三者都能实现 Next.js 应用的本地化。如果你需要**组件范围的内容**、**严格的 TypeScript 类型**、**构建时缺失键检查**、**摇树优化的字典**,以及**一流的 App Router + SEO 辅助功能**,那么**Intlayer** 是最完整、最现代的选择。
|
|
39
|
+
|
|
40
|
+
> 开发者常犯的一个误解是认为 `next-intl` 是 `react-intl` 的 Next.js 版本。事实并非如此——`next-intl` 由 [Amann](https://github.com/amannn) 维护,而 `react-intl` 由 [FormatJS](https://github.com/formatjs/formatjs) 维护。
|
|
34
41
|
|
|
35
42
|
---
|
|
36
43
|
|
|
37
|
-
##
|
|
44
|
+
## 简而言之
|
|
38
45
|
|
|
39
|
-
- **next-intl** -
|
|
40
|
-
- **next-i18next** -
|
|
41
|
-
- **Intlayer** - 面向组件的 Next.js 内容模型,**严格的
|
|
46
|
+
- **next-intl** - 轻量级、直接的消息格式化,具有扎实的 Next.js 支持。通常采用集中式目录;开发体验简单,但安全性和大规模维护主要依赖于你自己。
|
|
47
|
+
- **next-i18next** - i18next 的 Next.js 版本。生态系统成熟,支持通过插件(例如 ICU)扩展功能,但配置可能较为冗长,且随着项目增长目录趋向集中化。
|
|
48
|
+
- **Intlayer** - 面向组件的 Next.js 内容模型,**严格的 TS 类型检查**,**构建时校验**,**摇树优化**,**内置中间件和 SEO 辅助工具**,可选的**可视化编辑器/CMS**,以及**AI 辅助翻译**。
|
|
42
49
|
|
|
43
50
|
---
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
| Library | GitHub Stars | Total Commits | Last Commit | First Version | NPM Version | NPM Downloads |
|
|
53
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
54
|
+
| `aymericzip/intlayer` | [](https://github.com/aymericzip/intlayer/stargazers) | [](https://github.com/aymericzip/intlayer/commits) | [](https://github.com/aymericzip/intlayer/commits) | April 2024 | [](https://www.npmjs.com/package/intlayer) | [](https://www.npmjs.com/package/intlayer) |
|
|
55
|
+
| `amannn/next-intl` | [](https://github.com/amannn/next-intl/stargazers) | [](https://github.com/amannn/next-intl/commits) | [](https://github.com/amannn/next-intl/commits) | Nov 2020 | [](https://www.npmjs.com/package/next-intl) | [](https://www.npmjs.com/package/next-intl) |
|
|
56
|
+
| `i18next/i18next` | [](https://github.com/i18next/i18next/stargazers) | [](https://github.com/i18next/i18next/commits) | [](https://github.com/i18next/i18next/commits) | Jan 2012 | [](https://www.npmjs.com/package/i18next) | [](https://www.npmjs.com/package/i18next) |
|
|
57
|
+
| `i18next/next-i18next` | [](https://github.com/i18next/next-i18next/stargazers) | [](https://github.com/i18next/next-i18next/commits) | [](https://github.com/i18next/next-i18next/commits) | Nov 2018 | [](https://www.npmjs.com/package/next-i18next) | [](https://www.npmjs.com/package/next-i18next) |
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
| -------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
|
49
|
-
| **组件附近的翻译** | ✅ 是,内容与每个组件共置 | ❌ 否 | ❌ 否 |
|
|
50
|
-
| **TypeScript 集成** | ✅ 高级,自动生成严格类型 | ✅ 良好 | ⚠️ 基础 |
|
|
51
|
-
| **缺失翻译检测** | ✅ TypeScript 错误高亮和构建时错误/警告 | ⚠️ 运行时回退 | ⚠️ 运行时回退 |
|
|
52
|
-
| **丰富内容(JSX/Markdown/组件)** | ✅ 直接支持 | ❌ 不支持丰富节点 | ⚠️ 有限支持 |
|
|
53
|
-
| **AI 驱动的翻译** | ✅ 是,支持多个 AI 提供商。可使用您自己的 API 密钥。考虑您的应用程序和内容范围的上下文 | ❌ 否 | ❌ 否 |
|
|
54
|
-
| **可视化编辑器** | ✅ 是,本地可视化编辑器 + 可选 CMS;可外部化代码库内容;可嵌入 | ❌ 否 / 可通过外部本地化平台获得 | ❌ 否 / 可通过外部本地化平台获得 |
|
|
55
|
-
| **本地化路由** | ✅ 是,开箱即用支持本地化路径(兼容 Next.js 和 Vite) | ✅ 内置,App Router 支持 `[locale]` 路由段 | ✅ 内置 |
|
|
56
|
-
| **动态路由生成** | ✅ 是 | ✅ 是 | ✅ 是 |
|
|
57
|
-
| **复数形式** | ✅ 基于枚举的模式 | ✅ 良好 | ✅ 良好 |
|
|
58
|
-
| **格式化(日期、数字、货币)** | ✅ 优化的格式化器(底层使用 Intl) | ✅ 良好(Intl 辅助工具) | ✅ 良好(Intl 辅助工具) |
|
|
59
|
-
| **内容格式** | ✅ .tsx, .ts, .js, .json, .md, .txt, (.yaml 进行中) | ✅ .json, .js, .ts | ⚠️ .json |
|
|
60
|
-
| **ICU 支持** | ⚠️ 进行中 | ✅ 是 | ⚠️ 通过插件(`i18next-icu`) |
|
|
61
|
-
| **SEO 辅助工具(hreflang,网站地图)** | ✅ 内置工具:网站地图、robots.txt、元数据辅助工具 | ✅ 良好 | ✅ 良好 |
|
|
62
|
-
| **生态系统 / 社区** | ⚠️ 规模较小但增长迅速且活跃 | ✅ 中等规模,专注于 Next.js | ✅ 中等规模,专注于 Next.js |
|
|
63
|
-
| **服务器端渲染与服务器组件** | ✅ 是,针对 SSR / React 服务器组件进行了优化 | ⚠️ 支持页面级别,但需要在组件树上传递 t 函数给子服务器组件 | ⚠️ 支持页面级别,但需要在组件树上传递 t 函数给子服务器组件 |
|
|
64
|
-
| **Tree-shaking(仅加载使用的内容)** | ✅ 是,通过 Babel/SWC 插件在构建时按组件进行 | ⚠️ 部分支持 | ⚠️ 部分支持 |
|
|
65
|
-
| **懒加载** | ✅ 是,按语言环境 / 按字典 | ✅ 是(按路由/按语言环境),需要命名空间管理 | ✅ 是(按路由/按语言环境),需要命名空间管理 |
|
|
66
|
-
| **清除未使用内容** | ✅ 是,按字典在构建时 | ❌ 否,可以通过命名空间管理手动处理 | ❌ 否,可以通过命名空间管理手动处理 |
|
|
67
|
-
| **大型项目管理** | ✅ 鼓励模块化,适合设计系统 | ✅ 通过设置实现模块化 | ✅ 通过设置实现模块化 |
|
|
59
|
+
> 徽章会自动更新。快照会随时间变化。
|
|
68
60
|
|
|
69
61
|
---
|
|
70
62
|
|
|
71
|
-
##
|
|
63
|
+
## 并排功能比较(以 Next.js 为重点)
|
|
72
64
|
|
|
73
|
-
|
|
65
|
+
| 功能 | `next-intlayer` (Intlayer) | `next-intl` | `next-i18next` |
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
- **Intlayer**:鼓励将**每个组件**(或每个功能)的字典与其对应的代码**共置**。这降低了认知负担,简化了 UI 组件的复制/迁移,并减少了跨团队的冲突。未使用的内容也更容易被发现和清理。
|
|
67
|
+
> 徽章会自动更新。快照会随时间变化。
|
|
77
68
|
|
|
78
|
-
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 并排功能对比(以 Next.js 为重点)
|
|
72
|
+
|
|
73
|
+
| 功能 | `next-intlayer` (Intlayer) | `next-intl` | `next-i18next` |
|
|
74
|
+
| -------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
|
75
|
+
| **组件附近的翻译** | ✅ 是,内容与每个组件共置 | ❌ 否 | ❌ 否 |
|
|
76
|
+
| **TypeScript 集成** | ✅ 高级,自动生成严格类型 | ✅ 良好 | ⚠️ 基础 |
|
|
77
|
+
| **缺失翻译检测** | ✅ TypeScript 错误高亮和构建时错误/警告 | ⚠️ 运行时回退 | ⚠️ 运行时回退 |
|
|
78
|
+
| **丰富内容(JSX/Markdown/组件)** | ✅ 直接支持 | ❌ 不支持丰富节点设计 | ⚠️ 有限支持 |
|
|
79
|
+
| **AI驱动的翻译** | ✅ 支持多种AI提供商。可使用您自己的API密钥。考虑您的应用程序和内容范围的上下文 | ❌ 不支持 | ❌ 不支持 |
|
|
80
|
+
| **可视化编辑器** | ✅ 是,本地可视化编辑器 + 可选 CMS;可外部化代码库内容;可嵌入 | ❌ 否 / 通过外部本地化平台提供 | ❌ 否 / 通过外部本地化平台提供 |
|
|
81
|
+
| **本地化路由** | ✅ 是,开箱即用支持本地化路径(兼容 Next.js 和 Vite) | ✅ 内置,App Router 支持 `[locale]` 路由段 | ✅ 内置 |
|
|
82
|
+
| **动态路由生成** | ✅ 是 | ✅ 是 | ✅ 是 |
|
|
83
|
+
| **复数形式处理** | ✅ 基于枚举的模式 | ✅ 良好 | ✅ 良好 |
|
|
84
|
+
| **格式化(日期、数字、货币)** | ✅ 优化的格式化器(底层使用 Intl) | ✅ 良好(Intl 辅助工具) | ✅ 良好(Intl 辅助工具) |
|
|
85
|
+
| **内容格式** | ✅ .tsx, .ts, .js, .json, .md, .txt, (.yaml 进行中) | ✅ .json, .js, .ts | ⚠️ .json |
|
|
86
|
+
| **ICU 支持** | ⚠️ 进行中 | ✅ 是 | ⚠️ 通过插件 (`i18next-icu`) |
|
|
87
|
+
| **SEO 辅助工具(hreflang,网站地图)** | ✅ 内置工具:网站地图、robots.txt、元数据的辅助工具 | ✅ 良好 | ✅ 良好 |
|
|
88
|
+
| **生态系统 / 社区** | ⚠️ 规模较小但增长迅速且反应灵敏 | ✅ 良好 | ✅ 良好 |
|
|
89
|
+
| **服务器端渲染 & 服务器组件** | ✅ 支持,针对 SSR / React 服务器组件进行了优化 | ⚠️ 在页面级别支持,但需要在组件树上传递 t 函数给子服务器组件 | ⚠️ 在页面级别支持,但需要在组件树上传递 t 函数给子服务器组件 |
|
|
90
|
+
| **Tree-shaking(仅加载使用的内容)** | ✅ 是的,通过 Babel/SWC 插件在构建时按组件进行 | ⚠️ 部分支持 | ⚠️ 部分支持 |
|
|
91
|
+
| **懒加载** | ✅ 是的,按语言环境 / 按字典 | ✅ 是的(按路由/按语言环境),需要命名空间管理 | ✅ 是的(按路由/按语言环境),需要命名空间管理 |
|
|
92
|
+
| **清理未使用内容** | ✅ 是,构建时按字典清理 | ❌ 否,可通过命名空间管理手动处理 | ❌ 否,可通过命名空间管理手动处理 |
|
|
93
|
+
| **大型项目管理** | ✅ 鼓励模块化,适合设计系统 | ✅ 通过配置实现模块化 | ✅ 通过配置实现模块化 |
|
|
94
|
+
| **测试缺失的翻译(CLI/CI)** | ✅ CLI: `npx intlayer content test`(适合CI的审计) | ⚠️ 非内置;文档建议使用 `npx @lingual/i18n-check` | ⚠️ 非内置;依赖 i18next 工具 / 运行时 `saveMissing` |
|
|
79
95
|
|
|
80
96
|
---
|
|
81
97
|
|
|
82
|
-
|
|
98
|
+
## 介绍
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
- **next-i18next**:为钩子提供基础类型定义;**严格的键类型化需要额外的工具/配置**。
|
|
86
|
-
- **Intlayer**:**从您的内容生成严格类型**。**IDE 自动补全**和**编译时错误**可以在部署前捕获拼写错误和缺失的键。
|
|
100
|
+
Next.js 为你内置了国际化路由支持(例如区域段)。但该功能本身并不进行翻译。你仍然需要一个库来向用户呈现本地化内容。
|
|
87
101
|
|
|
88
|
-
|
|
102
|
+
市面上有许多 i18n 库,但在 Next.js 领域,当前有三个正在获得关注:next-i18next、next-intl 和 Intlayer。
|
|
89
103
|
|
|
90
104
|
---
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
## 架构与可扩展性
|
|
93
107
|
|
|
94
|
-
- **next-intl / next-i18next
|
|
95
|
-
- **Intlayer
|
|
108
|
+
- **next-intl / next-i18next**:默认采用每个语言环境的**集中式目录**(在 i18next 中还包括**命名空间**)。在早期阶段效果良好,但随着耦合度上升和键值频繁变动,往往会成为一个庞大的共享区域。
|
|
109
|
+
- **Intlayer**:鼓励采用与其服务代码**共置**的**每组件**(或每功能)字典。这降低了认知负担,简化了 UI 组件的复制/迁移,并减少了跨团队冲突。未使用的内容也更容易被发现和清理。
|
|
96
110
|
|
|
97
|
-
**重要原因:**
|
|
111
|
+
**重要原因:** 在大型代码库或设计系统架构中,**模块化内容**比单体目录更具扩展性。
|
|
98
112
|
|
|
99
113
|
---
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
## 包大小与依赖
|
|
116
|
+
|
|
117
|
+
构建应用程序后,bundle 是浏览器加载以渲染页面的 JavaScript。因此,bundle 大小对于应用性能非常重要。
|
|
118
|
+
|
|
119
|
+
在多语言应用的 bundle 环境中,有两个重要组成部分:
|
|
120
|
+
|
|
121
|
+
- 应用代码
|
|
122
|
+
- 浏览器加载的内容
|
|
123
|
+
|
|
124
|
+
## 应用代码
|
|
125
|
+
|
|
126
|
+
在这种情况下,应用代码的重要性较小。三种解决方案都支持 tree-shaking,意味着未使用的代码部分不会被包含在 bundle 中。
|
|
127
|
+
|
|
128
|
+
以下是三种解决方案在多语言应用中,浏览器加载的 JavaScript bundle 大小的对比。
|
|
129
|
+
|
|
130
|
+
如果应用中不需要任何格式化器,tree-shaking 后导出的函数列表将是:
|
|
131
|
+
|
|
132
|
+
- **next-intlayer**: `useIntlayer`,`useLocale`,`NextIntlClientProvider`,(包大小为 180.6 kB -> 78.6 kB(gzip))
|
|
133
|
+
- **next-intl**: `useTranslations`,`useLocale`,`NextIntlClientProvider`,(包大小为 101.3 kB -> 31.4 kB(gzip))
|
|
134
|
+
- **next-i18next**: `useTranslation`,`useI18n`,`I18nextProvider`,(包大小为 80.7 kB -> 25.5 kB(gzip))
|
|
135
|
+
|
|
136
|
+
这些函数只是围绕 React 上下文/状态的包装器,因此 i18n 库对包大小的总体影响很小。
|
|
137
|
+
|
|
138
|
+
> Intlayer 比 `next-intl` 和 `next-i18next` 略大,因为它在 `useIntlayer` 函数中包含了更多逻辑。这与 markdown 和 `intlayer-editor` 的集成有关。
|
|
139
|
+
|
|
140
|
+
## 内容和翻译
|
|
141
|
+
|
|
142
|
+
这部分内容常常被开发者忽视,但让我们考虑一个由10个页面和10种语言组成的应用程序的情况。为了简化计算,假设每个页面都包含100%独特的内容(实际上,许多内容在页面之间是冗余的,例如页面标题、页眉、页脚等)。
|
|
143
|
+
|
|
144
|
+
一个想访问 `/fr/about` 页面用户将加载该语言下的一个页面内容。忽略内容优化意味着不必要地加载了应用内容的8200% `((1 + (((10个页面 - 1) × (10种语言 - 1)))) × 100)`。你看到了问题吗?即使这些内容仍然是文本,虽然你可能更倾向于优化网站的图片,但你实际上是在全球范围内发送无用内容,并让用户的计算机白白处理这些内容。
|
|
145
|
+
|
|
146
|
+
两个重要的问题:
|
|
147
|
+
|
|
148
|
+
- **按路由拆分:**
|
|
149
|
+
|
|
150
|
+
> 如果我在 `/about` 页面,我不想加载 `/home` 页面的内容
|
|
151
|
+
|
|
152
|
+
- **按语言拆分:**
|
|
153
|
+
|
|
154
|
+
> 如果我在 `/fr/about` 页面,我不想加载 `/en/about` 页面的内容
|
|
155
|
+
|
|
156
|
+
同样,三种解决方案都意识到这些问题,并允许管理这些优化。三者之间的区别在于开发者体验(DX)。
|
|
157
|
+
|
|
158
|
+
`next-intl` 和 `next-i18next` 使用集中式方法管理翻译,允许按语言和子文件拆分 JSON。在 `next-i18next` 中,我们称这些 JSON 文件为“命名空间”;`next-intl` 允许声明消息。在 `intlayer` 中,我们称这些 JSON 文件为“词典”。
|
|
159
|
+
|
|
160
|
+
- 在 `next-intl` 的情况下,类似于 `next-i18next`,内容是在页面/布局级别加载的,然后这些内容被加载到一个上下文提供者中。这意味着开发者必须手动管理每个页面将要加载的 JSON 文件。
|
|
161
|
+
|
|
162
|
+
> 实际上,这意味着开发者经常跳过这种优化,倾向于为了简单起见在页面的上下文提供者中加载所有内容。
|
|
163
|
+
|
|
164
|
+
- 在 `intlayer` 的情况下,所有内容都在应用中加载。然后一个插件(`@intlayer/babel` / `@intlayer/swc`)负责通过只加载页面上使用的内容来优化包。因此,开发者不需要手动管理将要加载的字典。这允许更好的优化、更好的可维护性,并减少开发时间。
|
|
165
|
+
|
|
166
|
+
随着应用程序的增长(尤其是当多个开发人员共同开发应用时),常常会忘记从 JSON 文件中移除不再使用的内容。
|
|
167
|
+
|
|
168
|
+
> 请注意,在所有情况下(next-intl、next-i18next、intlayer),所有 JSON 都会被加载。
|
|
169
|
+
|
|
170
|
+
这就是 Intlayer 方法更高效的原因:如果某个组件不再使用,其对应的字典不会被加载到包中。
|
|
171
|
+
|
|
172
|
+
库如何处理回退也非常重要。假设应用默认语言是英语,用户访问 `/fr/about` 页面。如果法语翻译缺失,我们将考虑使用英语作为回退语言。
|
|
173
|
+
|
|
174
|
+
在 `next-intl` 和 `next-i18next` 的情况下,库需要加载与当前语言环境相关的 JSON,同时还要加载回退语言环境的 JSON。因此,假设所有内容都已被翻译,每个页面都会加载 100% 不必要的内容。**相比之下,`intlayer` 在词典构建时处理回退。因此,每个页面只会加载实际使用的内容。**
|
|
102
175
|
|
|
103
|
-
|
|
104
|
-
- **Intlayer** 进一步提供了 **i18n 中间件**(通过请求头/Cookie 进行语言环境检测)和 **辅助工具**,用于生成本地化 URL 以及 `<link rel="alternate" hreflang="…">` 标签。
|
|
176
|
+
下面是使用 `intlayer` 在 vite + react 应用中进行包大小优化的影响示例:
|
|
105
177
|
|
|
106
|
-
|
|
178
|
+
| 优化后的包 | 未优化的包 |
|
|
179
|
+
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
180
|
+
|  |  |
|
|
107
181
|
|
|
108
182
|
---
|
|
109
183
|
|
|
110
|
-
|
|
184
|
+
## TypeScript 与安全性
|
|
111
185
|
|
|
112
|
-
|
|
113
|
-
|
|
186
|
+
<Columns>
|
|
187
|
+
<Column>
|
|
114
188
|
|
|
115
|
-
|
|
189
|
+
**next-intl**
|
|
190
|
+
|
|
191
|
+
- 具备扎实的 TypeScript 支持,但**默认情况下键没有严格类型**;你需要手动维护安全模式。
|
|
192
|
+
|
|
193
|
+
</Column>
|
|
194
|
+
<Column>
|
|
195
|
+
|
|
196
|
+
**next-i18next**
|
|
197
|
+
|
|
198
|
+
- 钩子的基础类型定义;**严格的键类型需要额外的工具/配置**。
|
|
199
|
+
|
|
200
|
+
</Column>
|
|
201
|
+
<Column>
|
|
202
|
+
|
|
203
|
+
**intlayer**
|
|
204
|
+
|
|
205
|
+
- **从你的内容生成严格类型**。**IDE 自动补全**和**编译时错误**能在部署前捕获拼写错误和缺失的键。
|
|
206
|
+
|
|
207
|
+
</Column>
|
|
208
|
+
</Columns>
|
|
209
|
+
|
|
210
|
+
**重要原因:** 强类型将失败提前到**左侧**(CI/构建阶段),而非**右侧**(运行时)。
|
|
116
211
|
|
|
117
212
|
---
|
|
118
213
|
|
|
119
|
-
|
|
214
|
+
## 缺失翻译处理
|
|
215
|
+
|
|
216
|
+
**next-intl**
|
|
217
|
+
|
|
218
|
+
- 依赖**运行时回退**(例如显示键名或默认语言)。构建不会失败。
|
|
219
|
+
|
|
220
|
+
**next-i18next**
|
|
221
|
+
|
|
222
|
+
- 依赖**运行时回退**(例如显示键名或默认语言)。构建不会失败。
|
|
120
223
|
|
|
121
|
-
|
|
122
|
-
- **Intlayer**:在构建时进行**摇树优化**,并按字典/语言环境**懒加载**。未使用的内容不会被打包。
|
|
224
|
+
**intlayer**
|
|
123
225
|
|
|
124
|
-
|
|
226
|
+
- **构建时检测**,对缺失的语言或键发出**警告/错误**。
|
|
227
|
+
|
|
228
|
+
**重要原因:** 在构建时捕获缺口,防止生产环境出现“神秘字符串”,并符合严格的发布门控。
|
|
125
229
|
|
|
126
230
|
---
|
|
127
231
|
|
|
128
|
-
|
|
232
|
+
## 路由、中间件与 URL 策略
|
|
233
|
+
|
|
234
|
+
<Columns>
|
|
235
|
+
<Column>
|
|
236
|
+
|
|
237
|
+
**next-intl**
|
|
238
|
+
|
|
239
|
+
- 支持 **Next.js 本地化路由**,适用于 App Router。
|
|
129
240
|
|
|
130
|
-
|
|
131
|
-
|
|
241
|
+
</Column>
|
|
242
|
+
<Column>
|
|
132
243
|
|
|
133
|
-
|
|
244
|
+
**next-i18next**
|
|
245
|
+
|
|
246
|
+
- 支持 **Next.js 本地化路由**,适用于 App Router。
|
|
247
|
+
|
|
248
|
+
</Column>
|
|
249
|
+
<Column>
|
|
250
|
+
|
|
251
|
+
**intlayer**
|
|
252
|
+
|
|
253
|
+
- 包含上述所有功能,外加 **i18n 中间件**(通过请求头/Cookies 进行语言环境检测)和用于生成本地化 URL 及 `<link rel="alternate" hreflang="…">` 标签的 **辅助工具**。
|
|
254
|
+
|
|
255
|
+
</Column>
|
|
256
|
+
</Columns>
|
|
257
|
+
|
|
258
|
+
**重要性说明:** 减少自定义粘合层;实现跨语言环境的 **一致用户体验** 和 **干净的 SEO**。
|
|
134
259
|
|
|
135
260
|
---
|
|
136
261
|
|
|
137
|
-
##
|
|
262
|
+
## 服务器组件(RSC)对齐
|
|
263
|
+
|
|
264
|
+
<Columns>
|
|
265
|
+
<Column>
|
|
266
|
+
|
|
267
|
+
**next-intl**
|
|
268
|
+
|
|
269
|
+
- 支持 Next.js 13+。在混合设置中,通常需要通过组件树传递 t 函数/格式化器。
|
|
138
270
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
271
|
+
</Column>
|
|
272
|
+
<Column>
|
|
273
|
+
|
|
274
|
+
**next-i18next**
|
|
275
|
+
|
|
276
|
+
- 支持 Next.js 13+。在跨边界传递翻译工具时有类似的限制。
|
|
277
|
+
|
|
278
|
+
</Column>
|
|
279
|
+
<Column>
|
|
280
|
+
|
|
281
|
+
**intlayer**
|
|
282
|
+
|
|
283
|
+
- 支持 Next.js 13+,并通过一致的 API 和面向 RSC 的提供者平滑处理**服务器/客户端边界**,避免来回传递格式化器或 t 函数。
|
|
284
|
+
|
|
285
|
+
</Column>
|
|
286
|
+
</Columns>
|
|
287
|
+
|
|
288
|
+
**重要原因:** 更清晰的思维模型,减少混合树中的边缘情况。
|
|
142
289
|
|
|
143
290
|
---
|
|
144
291
|
|
|
145
|
-
##
|
|
292
|
+
## 开发体验(DX)、工具链与维护
|
|
293
|
+
|
|
294
|
+
<Columns>
|
|
295
|
+
<Column>
|
|
296
|
+
|
|
297
|
+
**next-intl**
|
|
298
|
+
|
|
299
|
+
- 通常与外部本地化平台和编辑工作流配合使用。
|
|
300
|
+
|
|
301
|
+
</Column>
|
|
302
|
+
<Column>
|
|
303
|
+
|
|
304
|
+
**next-i18next**
|
|
305
|
+
|
|
306
|
+
- 通常与外部本地化平台和编辑工作流配合使用。
|
|
307
|
+
|
|
308
|
+
</Column>
|
|
309
|
+
<Column>
|
|
310
|
+
|
|
311
|
+
**intlayer**
|
|
312
|
+
|
|
313
|
+
- 提供一个**免费的可视化编辑器**和**可选的CMS**(支持Git友好或外部化),以及一个**VSCode扩展**和使用您自己的提供商密钥的**AI辅助翻译**。
|
|
314
|
+
|
|
315
|
+
</Column>
|
|
316
|
+
</Columns>
|
|
317
|
+
|
|
318
|
+
**重要原因:** 降低运营成本,缩短开发者与内容作者之间的反馈周期。
|
|
319
|
+
|
|
320
|
+
## 与本地化平台(TMS)的集成
|
|
321
|
+
|
|
322
|
+
大型组织通常依赖诸如 **Crowdin**、**Phrase**、**Lokalise**、**Localizely** 或 **Localazy** 等翻译管理系统(TMS)。
|
|
323
|
+
|
|
324
|
+
- **公司关心的原因**
|
|
325
|
+
- **协作与角色**:涉及多方参与者:开发者、产品经理、翻译人员、审核人员、市场团队。
|
|
326
|
+
- **规模与效率**:持续本地化,实时上下文审查。
|
|
327
|
+
|
|
328
|
+
- **next-intl / next-i18next**
|
|
329
|
+
- 通常使用**集中式 JSON 目录**,因此与 TMS 的导出/导入非常直接。
|
|
330
|
+
- 针对上述平台有成熟的生态系统和示例/集成。
|
|
331
|
+
|
|
332
|
+
- **Intlayer**
|
|
333
|
+
- 鼓励使用**去中心化的、按组件划分的字典**,并支持**TypeScript/TSX/JS/JSON/MD** 内容。
|
|
334
|
+
- 这提升了代码的模块化,但当工具期望集中式、扁平的 JSON 文件时,可能会使即插即用的 TMS 集成变得更困难。
|
|
335
|
+
- Intlayer 提供了替代方案:**AI 辅助翻译**(使用您自己的提供商密钥)、**可视化编辑器/CMS**,以及用于捕获和预填空缺的**CLI/CI** 工作流。
|
|
336
|
+
|
|
337
|
+
> 注意:`next-intl` 和 `i18next` 也支持 TypeScript 目录。如果你的团队将消息存储在 `.ts` 文件中,或者按功能模块分散存储,可能会遇到类似的 TMS 集成难题。然而,许多 `next-intl` 的配置仍然集中在 `locales/` 文件夹中,这使得重构为 JSON 以适配 TMS 更加容易。
|
|
338
|
+
|
|
339
|
+
## 开发者体验
|
|
340
|
+
|
|
341
|
+
本部分将对这三种解决方案进行深入比较。我们不会仅考虑各方案“入门”文档中描述的简单案例,而是会考虑一个更接近真实项目的实际用例。
|
|
342
|
+
|
|
343
|
+
### 应用结构
|
|
344
|
+
|
|
345
|
+
应用结构对于确保代码库的良好可维护性非常重要。
|
|
346
|
+
|
|
347
|
+
<Tab defaultTab="next-intl" group='techno'>
|
|
348
|
+
|
|
349
|
+
<TabItem label="next-i18next" value="next-i18next">
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
.
|
|
353
|
+
├── i18n.config.ts
|
|
354
|
+
└── src
|
|
355
|
+
├── locales
|
|
356
|
+
│ ├── en
|
|
357
|
+
│ │ ├── common.json
|
|
358
|
+
│ │ └── about.json
|
|
359
|
+
│ └── fr
|
|
360
|
+
│ ├── common.json
|
|
361
|
+
│ └── about.json
|
|
362
|
+
├── app
|
|
363
|
+
│ ├── i18n
|
|
364
|
+
│ │ └── server.ts
|
|
365
|
+
│ └── [locale]
|
|
366
|
+
│ ├── layout.tsx
|
|
367
|
+
│ └── about.tsx
|
|
368
|
+
└── components
|
|
369
|
+
├── I18nProvider.tsx
|
|
370
|
+
├── ClientComponent.tsx
|
|
371
|
+
└── ServerComponent.tsx
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
</TabItem>
|
|
375
|
+
<TabItem label="next-intl" value="next-intl">
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
.
|
|
379
|
+
├── i18n.ts
|
|
380
|
+
├── locales
|
|
381
|
+
│ ├── en
|
|
382
|
+
│ │ ├── home.json
|
|
383
|
+
│ │ └── navbar.json
|
|
384
|
+
│ ├── fr
|
|
385
|
+
│ │ ├── home.json
|
|
386
|
+
│ │ └── navbar.json
|
|
387
|
+
│ └── es
|
|
388
|
+
│ ├── home.json
|
|
389
|
+
│ └── navbar.json
|
|
390
|
+
└── src
|
|
391
|
+
├── middleware.ts
|
|
392
|
+
├── app
|
|
393
|
+
│ ├── i18n
|
|
394
|
+
│ │ └── server.ts
|
|
395
|
+
│ └── [locale]
|
|
396
|
+
│ └── home.tsx
|
|
397
|
+
└── components
|
|
398
|
+
└── Navbar
|
|
399
|
+
└── index.tsx
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
</TabItem>
|
|
403
|
+
<TabItem label="intlayer" value="intlayer">
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
.
|
|
407
|
+
├── intlayer.config.ts
|
|
408
|
+
└── src
|
|
409
|
+
├── middleware.ts
|
|
410
|
+
├── app
|
|
411
|
+
│ └── [locale]
|
|
412
|
+
│ ├── layout.tsx
|
|
413
|
+
│ └── home
|
|
414
|
+
│ ├── index.tsx
|
|
415
|
+
│ └── index.content.ts
|
|
416
|
+
└── components
|
|
417
|
+
└── Navbar
|
|
418
|
+
├── index.tsx
|
|
419
|
+
└── index.content.ts
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
</TabItem>
|
|
423
|
+
</Tab>
|
|
424
|
+
|
|
425
|
+
#### 比较
|
|
426
|
+
|
|
427
|
+
- **next-intl / next-i18next**:集中式目录(JSON;命名空间/消息)。结构清晰,易于与翻译平台集成,但随着应用增长,可能导致更多跨文件编辑。
|
|
428
|
+
- **Intlayer**:每个组件配有 `.content.{ts|js|json}` 字典,与组件共存。更便于组件重用和局部推理;增加文件数量,依赖构建时工具。
|
|
429
|
+
|
|
430
|
+
#### 设置和加载内容
|
|
431
|
+
|
|
432
|
+
正如之前提到的,您必须优化每个 JSON 文件导入到代码中的方式。
|
|
433
|
+
库如何处理内容加载非常重要。
|
|
434
|
+
|
|
435
|
+
<Tab defaultTab="next-intl" group='techno'>
|
|
436
|
+
<TabItem label="next-i18next" value="next-i18next">
|
|
437
|
+
|
|
438
|
+
```tsx fileName="next-i18next.config.js"
|
|
439
|
+
module.exports = {
|
|
440
|
+
i18n: {
|
|
441
|
+
locales: ["en", "fr", "es"],
|
|
442
|
+
defaultLocale: "en",
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
```tsx fileName="src/app/_app.tsx"
|
|
448
|
+
import { appWithTranslation } from "next-i18next";
|
|
449
|
+
|
|
450
|
+
const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />;
|
|
451
|
+
|
|
452
|
+
export default appWithTranslation(MyApp);
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
```tsx fileName="src/app/[locale]/about/page.tsx"
|
|
456
|
+
import type { GetStaticProps } from "next";
|
|
457
|
+
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
|
458
|
+
import { useTranslation } from "next-i18next";
|
|
459
|
+
import { I18nextProvider, initReactI18next } from "react-i18next";
|
|
460
|
+
import { createInstance } from "i18next";
|
|
461
|
+
import { ClientComponent, ServerComponent } from "@components";
|
|
462
|
+
|
|
463
|
+
export default function HomePage({ locale }: { locale: string }) {
|
|
464
|
+
// 明确声明该组件使用的命名空间
|
|
465
|
+
const resources = await loadMessagesFor(locale); // 你的加载器(JSON 等)
|
|
466
|
+
|
|
467
|
+
const i18n = createInstance();
|
|
468
|
+
i18n.use(initReactI18next).init({
|
|
469
|
+
lng: locale,
|
|
470
|
+
fallbackLng: "en",
|
|
471
|
+
resources,
|
|
472
|
+
ns: ["common", "about"],
|
|
473
|
+
defaultNS: "common",
|
|
474
|
+
interpolation: { escapeValue: false },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const { t } = useTranslation("about");
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
<I18nextProvider i18n={i18n}>
|
|
481
|
+
<main>
|
|
482
|
+
<h1>{t("title")}</h1>
|
|
483
|
+
<ClientComponent />
|
|
484
|
+
<ServerComponent />
|
|
485
|
+
</main>
|
|
486
|
+
</I18nextProvider>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
|
491
|
+
// 仅预加载此页面所需的命名空间
|
|
492
|
+
return {
|
|
493
|
+
props: {
|
|
494
|
+
...(await serverSideTranslations(locale ?? "en", ["common", "about"])),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
};
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
</TabItem>
|
|
501
|
+
<TabItem label="next-intl" value="next-intl">
|
|
502
|
+
|
|
503
|
+
```tsx fileName="i18n.ts"
|
|
504
|
+
import { getRequestConfig } from "next-intl/server";
|
|
505
|
+
import { notFound } from "next/navigation";
|
|
506
|
+
|
|
507
|
+
// 可以从共享配置中导入
|
|
508
|
+
const locales = ["en", "fr", "es"];
|
|
509
|
+
|
|
510
|
+
export default getRequestConfig(async ({ locale }) => {
|
|
511
|
+
// 验证传入的 `locale` 参数是否有效
|
|
512
|
+
if (!locales.includes(locale as any)) notFound();
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
messages: (await import(`../messages/${locale}.json`)).default,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
```tsx fileName="src/app/[locale]/about/layout.tsx"
|
|
521
|
+
import { NextIntlClientProvider } from "next-intl";
|
|
522
|
+
import { getMessages, unstable_setRequestLocale } from "next-intl/server";
|
|
523
|
+
import pick from "lodash/pick";
|
|
524
|
+
|
|
525
|
+
export default async function LocaleLayout({
|
|
526
|
+
children,
|
|
527
|
+
params,
|
|
528
|
+
}: {
|
|
529
|
+
children: React.ReactNode;
|
|
530
|
+
params: { locale: string };
|
|
531
|
+
}) {
|
|
532
|
+
const { locale } = params;
|
|
533
|
+
|
|
534
|
+
// 为此服务器渲染(RSC)设置活动请求语言环境
|
|
535
|
+
unstable_setRequestLocale(locale);
|
|
536
|
+
|
|
537
|
+
// 消息通过 src/i18n/request.ts 在服务器端加载
|
|
538
|
+
// (参见 next-intl 文档)。这里我们只推送客户端组件所需的子集
|
|
539
|
+
// 以优化负载。
|
|
540
|
+
const messages = await getMessages();
|
|
541
|
+
const clientMessages = pick(messages, ["common", "about"]);
|
|
542
|
+
|
|
543
|
+
const rtlLocales = ["ar", "he", "fa", "ur"];
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<html lang={locale} dir={rtlLocales.includes(locale) ? "rtl" : "ltr"}>
|
|
547
|
+
<body>
|
|
548
|
+
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
|
549
|
+
{children}
|
|
550
|
+
</NextIntlClientProvider>
|
|
551
|
+
</body>
|
|
552
|
+
</html>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```tsx fileName="src/app/[locale]/about/page.tsx"
|
|
558
|
+
import { getTranslations } from "next-intl/server";
|
|
559
|
+
import { ClientComponent, ServerComponent } from "@components";
|
|
560
|
+
|
|
561
|
+
export default async function LandingPage({
|
|
562
|
+
params,
|
|
563
|
+
}: {
|
|
564
|
+
params: { locale: string };
|
|
565
|
+
}) {
|
|
566
|
+
// 严格的服务器端加载(不会在客户端水合)
|
|
567
|
+
const t = await getTranslations("about");
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<main>
|
|
571
|
+
<h1>{t("title")}</h1>
|
|
572
|
+
<ClientComponent />
|
|
573
|
+
<ServerComponent />
|
|
574
|
+
</main>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
</TabItem>
|
|
580
|
+
<TabItem label="intlayer" value="intlayer">
|
|
581
|
+
|
|
582
|
+
```tsx fileName="intlayer.config.ts"
|
|
583
|
+
export default {
|
|
584
|
+
internationalization: {
|
|
585
|
+
locales: ["en", "fr", "es"],
|
|
586
|
+
defaultLocale: "en",
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
```tsx fileName="src/app/[locale]/layout.tsx"
|
|
592
|
+
import { getHTMLTextDir } from "intlayer";
|
|
593
|
+
import {
|
|
594
|
+
IntlayerClientProvider,
|
|
595
|
+
generateStaticParams,
|
|
596
|
+
type NextLayoutIntlayer,
|
|
597
|
+
} from "next-intlayer";
|
|
598
|
+
|
|
599
|
+
export const dynamic = "force-static";
|
|
600
|
+
|
|
601
|
+
const LandingLayout: NextLayoutIntlayer = async ({ children, params }) => {
|
|
602
|
+
const { locale } = await params;
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<html lang={locale} dir={getHTMLTextDir(locale)}>
|
|
606
|
+
<IntlayerClientProvider locale={locale}>
|
|
607
|
+
{children}
|
|
608
|
+
</IntlayerClientProvider>
|
|
609
|
+
</html>
|
|
610
|
+
);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
export default LandingLayout;
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
```tsx fileName="src/app/[locale]/about/page.tsx"
|
|
617
|
+
import { PageContent } from "@components/PageContent";
|
|
618
|
+
import type { NextPageIntlayer } from "next-intlayer";
|
|
619
|
+
import { IntlayerServerProvider, useIntlayer } from "next-intlayer/server";
|
|
620
|
+
import { ClientComponent, ServerComponent } from "@components";
|
|
621
|
+
|
|
622
|
+
const LandingPage: NextPageIntlayer = async ({ params }) => {
|
|
623
|
+
const { locale } = await params;
|
|
624
|
+
const { title } = useIntlayer("about", locale);
|
|
625
|
+
|
|
626
|
+
return (
|
|
627
|
+
<IntlayerServerProvider locale={locale}>
|
|
628
|
+
<main>
|
|
629
|
+
<h1>{title}</h1>
|
|
630
|
+
<ClientComponent />
|
|
631
|
+
<ServerComponent />
|
|
632
|
+
</main>
|
|
633
|
+
</IntlayerServerProvider>
|
|
634
|
+
);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
export default LandingPage;
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
</TabItem>
|
|
641
|
+
</Tab>
|
|
642
|
+
|
|
643
|
+
#### 比较
|
|
644
|
+
|
|
645
|
+
这三者都支持按语言环境加载内容和提供者。
|
|
646
|
+
|
|
647
|
+
- 使用 **next-intl/next-i18next** 时,通常会根据路由加载选定的消息/命名空间,并在需要的地方放置提供者。
|
|
648
|
+
|
|
649
|
+
- 使用 **Intlayer** 时,增加了构建时分析以推断使用情况,这可以减少手动连接,并可能允许使用单一根提供者。
|
|
650
|
+
|
|
651
|
+
根据团队偏好在显式控制和自动化之间进行选择。
|
|
652
|
+
|
|
653
|
+
### 在客户端组件中的使用
|
|
654
|
+
|
|
655
|
+
让我们来看一个渲染计数器的客户端组件示例。
|
|
656
|
+
|
|
657
|
+
<Tab defaultTab="next-intl" group='techno'>
|
|
658
|
+
<TabItem label="next-i18next" value="next-i18next">
|
|
659
|
+
|
|
660
|
+
**翻译(必须是真实的 JSON,位于 `public/locales/...`)**
|
|
661
|
+
|
|
662
|
+
```json fileName="public/locales/en/about.json"
|
|
663
|
+
{
|
|
664
|
+
"counter": {
|
|
665
|
+
"label": "Counter",
|
|
666
|
+
"increment": "Increment"
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
```json fileName="public/locales/fr/about.json"
|
|
672
|
+
{
|
|
673
|
+
"counter": {
|
|
674
|
+
"label": "计数器",
|
|
675
|
+
"increment": "增加"
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**客户端组件**
|
|
681
|
+
|
|
682
|
+
```tsx fileName="src/components/ClientComponentExample.tsx"
|
|
683
|
+
"use client";
|
|
684
|
+
|
|
685
|
+
import React, { useMemo, useState } from "react";
|
|
686
|
+
import { useTranslation } from "next-i18next";
|
|
687
|
+
|
|
688
|
+
const ClientComponentExample = () => {
|
|
689
|
+
const { t, i18n } = useTranslation("about");
|
|
690
|
+
const [count, setCount] = useState(0);
|
|
691
|
+
|
|
692
|
+
// next-i18next 不提供 useNumber;使用 Intl.NumberFormat
|
|
693
|
+
const numberFormat = new Intl.NumberFormat(i18n.language);
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<div>
|
|
697
|
+
<p>{numberFormat.format(count)}</p>
|
|
698
|
+
<button
|
|
699
|
+
aria-label={t("counter.label")}
|
|
700
|
+
onClick={() => setCount((count) => count + 1)}
|
|
701
|
+
>
|
|
702
|
+
{t("counter.increment")}
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
);
|
|
706
|
+
};
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
> 别忘了在页面的 serverSideTranslations 中添加 "about" 命名空间
|
|
710
|
+
> 这里使用的是 react 19.x.x 版本,但对于较低版本,你需要使用 useMemo 来存储格式化器的实例,因为它是一个开销较大的函数
|
|
711
|
+
|
|
712
|
+
</TabItem>
|
|
713
|
+
<TabItem label="next-intl" value="next-intl">
|
|
714
|
+
|
|
715
|
+
**翻译(复用结构;根据需要加载到 next-intl 消息中)**
|
|
716
|
+
|
|
717
|
+
```json fileName="locales/en/about.json"
|
|
718
|
+
{
|
|
719
|
+
"counter": {
|
|
720
|
+
"label": "Counter",
|
|
721
|
+
"increment": "Increment"
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
```json fileName="locales/fr/about.json"
|
|
727
|
+
{
|
|
728
|
+
"counter": {
|
|
729
|
+
"label": "Compteur",
|
|
730
|
+
"increment": "Incrémenter"
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
**客户端组件**
|
|
736
|
+
|
|
737
|
+
```tsx fileName="src/components/ClientComponentExample.tsx"
|
|
738
|
+
"use client";
|
|
739
|
+
|
|
740
|
+
import React, { useState } from "react";
|
|
741
|
+
import { useTranslations, useFormatter } from "next-intl";
|
|
742
|
+
|
|
743
|
+
const ClientComponentExample = () => {
|
|
744
|
+
// 直接作用于嵌套对象的作用域
|
|
745
|
+
const t = useTranslations("about.counter");
|
|
746
|
+
const format = useFormatter();
|
|
747
|
+
const [count, setCount] = useState(0);
|
|
748
|
+
|
|
749
|
+
return (
|
|
750
|
+
<div>
|
|
751
|
+
<p>{format.number(count)}</p>
|
|
752
|
+
<button
|
|
753
|
+
aria-label={t("label")}
|
|
754
|
+
onClick={() => setCount((count) => count + 1)}
|
|
755
|
+
>
|
|
756
|
+
{t("increment")}
|
|
757
|
+
</button>
|
|
758
|
+
</div>
|
|
759
|
+
);
|
|
760
|
+
};
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
> 不要忘记在页面客户端消息中添加 "about" 消息
|
|
764
|
+
|
|
765
|
+
</TabItem>
|
|
766
|
+
<TabItem label="intlayer" value="intlayer">
|
|
767
|
+
|
|
768
|
+
**内容**
|
|
769
|
+
|
|
770
|
+
```ts fileName="src/components/ClientComponentExample/index.content.ts"
|
|
771
|
+
import { t, type Dictionary } from "intlayer";
|
|
772
|
+
|
|
773
|
+
const counterContent = {
|
|
774
|
+
key: "counter",
|
|
775
|
+
content: {
|
|
776
|
+
label: t({ zh: "计数器", en: "Counter", fr: "Compteur" }),
|
|
777
|
+
increment: t({ zh: "递增", en: "Increment", fr: "Incrémenter" }),
|
|
778
|
+
},
|
|
779
|
+
} satisfies Dictionary;
|
|
780
|
+
|
|
781
|
+
export default counterContent;
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
**客户端组件**
|
|
785
|
+
|
|
786
|
+
```tsx fileName="src/components/ClientComponentExample/index.tsx"
|
|
787
|
+
"use client";
|
|
788
|
+
|
|
789
|
+
import React, { useState } from "react";
|
|
790
|
+
import { useNumber, useIntlayer } from "next-intlayer";
|
|
791
|
+
|
|
792
|
+
const ClientComponentExample = () => {
|
|
793
|
+
const [count, setCount] = useState(0);
|
|
794
|
+
const { label, increment } = useIntlayer("counter"); // 返回字符串
|
|
795
|
+
const { number } = useNumber();
|
|
796
|
+
|
|
797
|
+
return (
|
|
798
|
+
<div>
|
|
799
|
+
<p>{number(count)}</p>
|
|
800
|
+
<button aria-label={label} onClick={() => setCount((count) => count + 1)}>
|
|
801
|
+
{increment}
|
|
802
|
+
</button>
|
|
803
|
+
</div>
|
|
804
|
+
);
|
|
805
|
+
};
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
</TabItem>
|
|
809
|
+
</Tab>
|
|
810
|
+
|
|
811
|
+
#### 比较
|
|
812
|
+
|
|
813
|
+
- **数字格式化**
|
|
814
|
+
- **next-i18next**:没有 `useNumber`;使用 `Intl.NumberFormat`(或 i18next-icu)。
|
|
815
|
+
- **next-intl**:`useFormatter().number(value)`。
|
|
816
|
+
- **Intlayer**:内置 `useNumber()`。
|
|
817
|
+
|
|
818
|
+
- **键**
|
|
819
|
+
- 保持嵌套结构(`about.counter.label`),并相应地限定钩子的作用域(`useTranslation("about")` + `t("counter.label")` 或 `useTranslations("about.counter")` + `t("label")`)。
|
|
820
|
+
|
|
821
|
+
- **文件位置**
|
|
822
|
+
- **next-i18next** 期望 JSON 文件位于 `public/locales/{lng}/{ns}.json`。
|
|
823
|
+
- **next-intl** 灵活;根据配置加载消息。
|
|
824
|
+
- **Intlayer** 将内容存储在 TS/JS 字典中,并通过键解析。
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
### 在服务器组件中的使用
|
|
829
|
+
|
|
830
|
+
我们以一个 UI 组件为例。该组件是一个服务器组件,并且应该能够作为客户端组件的子组件插入。(页面(服务器组件)-> 客户端组件 -> 服务器组件)。由于该组件可以作为客户端组件的子组件插入,因此它不能是异步的。
|
|
831
|
+
|
|
832
|
+
<Tab defaultTab="next-intl" group='techno'>
|
|
833
|
+
<TabItem label="next-i18next" value="next-i18next">
|
|
834
|
+
|
|
835
|
+
```tsx fileName="src/pages/about.tsx"
|
|
836
|
+
import type { GetStaticProps } from "next";
|
|
837
|
+
import { useTranslation } from "next-i18next";
|
|
838
|
+
|
|
839
|
+
type ServerComponentProps = {
|
|
840
|
+
count: number;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const ServerComponent = ({ count }: ServerComponentProps) => {
|
|
844
|
+
const { t, i18n } = useTranslation("about");
|
|
845
|
+
const formatted = new Intl.NumberFormat(i18n.language).format(count);
|
|
846
|
+
|
|
847
|
+
return (
|
|
848
|
+
<div>
|
|
849
|
+
<p>{formatted}</p>
|
|
850
|
+
<button aria-label={t("counter.label")}>{t("counter.increment")}</button>
|
|
851
|
+
</div>
|
|
852
|
+
);
|
|
853
|
+
};
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
</TabItem>
|
|
857
|
+
<TabItem label="next-intl" value="next-intl">
|
|
858
|
+
|
|
859
|
+
```tsx fileName="src/components/ServerComponent.tsx"
|
|
860
|
+
type ServerComponentProps = {
|
|
861
|
+
count: number;
|
|
862
|
+
t: (key: string) => string;
|
|
863
|
+
formatter: Intl.NumberFormat;
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const ServerComponent = ({ t, count, formatter }: ServerComponentProps) => {
|
|
867
|
+
const formatted = formatter.format(count);
|
|
868
|
+
|
|
869
|
+
return (
|
|
870
|
+
<div>
|
|
871
|
+
<p>{formatted}</p>
|
|
872
|
+
<button aria-label={t("label")}>{t("increment")}</button>
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
};
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
> 由于服务器组件不能是异步的,因此需要将翻译函数和格式化函数作为属性传递。
|
|
879
|
+
>
|
|
880
|
+
> - `const t = await getTranslations("about.counter");`
|
|
881
|
+
> - `const formatter = await getFormatter().then((formatter) => formatter.number());`
|
|
882
|
+
|
|
883
|
+
</TabItem>
|
|
884
|
+
<TabItem label="intlayer" value="intlayer">
|
|
885
|
+
|
|
886
|
+
```tsx fileName="src/components/ServerComponent.tsx"
|
|
887
|
+
import { useIntlayer, useNumber } from "next-intlayer/server";
|
|
888
|
+
|
|
889
|
+
const ServerComponent = ({ count }: { count: number }) => {
|
|
890
|
+
const { label, increment } = useIntlayer("counter");
|
|
891
|
+
const { number } = useNumber();
|
|
892
|
+
|
|
893
|
+
return (
|
|
894
|
+
<div>
|
|
895
|
+
<p>{number(count)}</p>
|
|
896
|
+
<button aria-label={label}>{increment}</button>
|
|
897
|
+
</div>
|
|
898
|
+
);
|
|
899
|
+
};
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
</TabItem>
|
|
903
|
+
</Tab>
|
|
904
|
+
|
|
905
|
+
> Intlayer 通过 `next-intlayer/server` 提供了**服务器安全**的钩子。为了工作,`useIntlayer` 和 `useNumber` 使用类似钩子的语法,类似于客户端钩子,但在底层依赖于服务器上下文(`IntlayerServerProvider`)。
|
|
906
|
+
|
|
907
|
+
### 元数据 / 网站地图 / 机器人协议
|
|
908
|
+
|
|
909
|
+
翻译内容固然重要,但人们通常忘记国际化的主要目标是让你的网站对全世界更具可见性。I18n 是提升你网站可见性的一个强大杠杆。
|
|
910
|
+
|
|
911
|
+
以下是关于多语言 SEO 的一些最佳实践列表。
|
|
912
|
+
|
|
913
|
+
- 在 `<head>` 标签中设置 hreflang 元标签
|
|
914
|
+
> 这有助于搜索引擎理解页面支持哪些语言
|
|
915
|
+
- 在 sitemap.xml 中列出所有页面的翻译,使用 `http://www.w3.org/1999/xhtml` XML 模式
|
|
916
|
+
>
|
|
917
|
+
- 不要忘记在 robots.txt 中排除带前缀的页面(例如 `/dashboard`,以及 `/fr/dashboard`,`/es/dashboard`)
|
|
918
|
+
>
|
|
919
|
+
- 使用自定义 Link 组件重定向到最本地化的页面(例如法语中 `<a href="/fr/about">A propos</a>`)
|
|
920
|
+
>
|
|
921
|
+
|
|
922
|
+
开发者经常忘记正确地在不同语言环境中引用他们的页面。
|
|
923
|
+
|
|
924
|
+
<Tab defaultTab="next-intl" group='techno'>
|
|
925
|
+
|
|
926
|
+
<TabItem label="next-i18next" value="next-i18next">
|
|
927
|
+
|
|
928
|
+
```ts fileName="i18n.config.ts"
|
|
929
|
+
export const locales = ["en", "fr"] as const;
|
|
930
|
+
export type Locale = (typeof locales)[number];
|
|
931
|
+
export const defaultLocale: Locale = "en";
|
|
932
|
+
|
|
933
|
+
export function localizedPath(locale: string, path: string) {
|
|
934
|
+
return locale === defaultLocale ? path : "/" + locale + path;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const ORIGIN = "https://example.com";
|
|
938
|
+
export function abs(locale: string, path: string) {
|
|
939
|
+
return ORIGIN + localizedPath(locale, path);
|
|
940
|
+
}
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
```tsx fileName="src/app/[locale]/about/layout.tsx"
|
|
944
|
+
import type { Metadata } from "next";
|
|
945
|
+
import { locales, defaultLocale, localizedPath } from "@/i18n.config";
|
|
946
|
+
|
|
947
|
+
export async function generateMetadata({
|
|
948
|
+
params,
|
|
949
|
+
}: {
|
|
950
|
+
params: { locale: string };
|
|
951
|
+
}): Promise<Metadata> {
|
|
952
|
+
const { locale } = params;
|
|
953
|
+
|
|
954
|
+
// 动态导入正确的 JSON 文件
|
|
955
|
+
const messages = (
|
|
956
|
+
await import("@/../public/locales/" + locale + "/about.json")
|
|
957
|
+
).default;
|
|
958
|
+
|
|
959
|
+
const languages = Object.fromEntries(
|
|
960
|
+
locales.map((locale) => [locale, localizedPath(locale, "/about")])
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
title: messages.title,
|
|
965
|
+
description: messages.description,
|
|
966
|
+
alternates: {
|
|
967
|
+
canonical: localizedPath(locale, "/about"),
|
|
968
|
+
languages: { ...languages, "x-default": "/about" },
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export default async function AboutPage() {
|
|
974
|
+
return <h1>关于</h1>;
|
|
975
|
+
}
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
```ts fileName="src/app/sitemap.ts"
|
|
979
|
+
import type { MetadataRoute } from "next";
|
|
980
|
+
import { locales, defaultLocale, abs } from "@/i18n.config";
|
|
981
|
+
|
|
982
|
+
export default function sitemap(): MetadataRoute.Sitemap {
|
|
983
|
+
const languages = Object.fromEntries(
|
|
984
|
+
locales.map((locale) => [locale, abs(locale, "/about")])
|
|
985
|
+
);
|
|
986
|
+
return [
|
|
987
|
+
{
|
|
988
|
+
url: abs(defaultLocale, "/about"),
|
|
989
|
+
lastModified: new Date(),
|
|
990
|
+
changeFrequency: "monthly",
|
|
991
|
+
priority: 0.7,
|
|
992
|
+
alternates: { languages },
|
|
993
|
+
},
|
|
994
|
+
];
|
|
995
|
+
}
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
```ts fileName="src/app/robots.ts"
|
|
999
|
+
import type { MetadataRoute } from "next";
|
|
1000
|
+
import { locales, defaultLocale, localizedPath } from "@/i18n.config";
|
|
1001
|
+
|
|
1002
|
+
const ORIGIN = "https://example.com";
|
|
1003
|
+
|
|
1004
|
+
const expandAllLocales = (path: string) => [
|
|
1005
|
+
localizedPath(defaultLocale, path),
|
|
1006
|
+
...locales
|
|
1007
|
+
.filter((locale) => locale !== defaultLocale)
|
|
1008
|
+
.map((locale) => localizedPath(locale, path)),
|
|
1009
|
+
];
|
|
1010
|
+
|
|
1011
|
+
export default function robots(): MetadataRoute.Robots {
|
|
1012
|
+
const disallow = [
|
|
1013
|
+
...expandAllLocales("/dashboard"),
|
|
1014
|
+
...expandAllLocales("/admin"),
|
|
1015
|
+
];
|
|
1016
|
+
|
|
1017
|
+
return {
|
|
1018
|
+
rules: { userAgent: "*", allow: ["/"], disallow },
|
|
1019
|
+
host: ORIGIN,
|
|
1020
|
+
sitemap: ORIGIN + "/sitemap.xml",
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
</TabItem>
|
|
1026
|
+
<TabItem label="next-intl" value="next-intl">
|
|
1027
|
+
|
|
1028
|
+
```tsx fileName="src/app/[locale]/about/layout.tsx"
|
|
1029
|
+
import type { Metadata } from "next";
|
|
1030
|
+
import { locales, defaultLocale } from "@/i18n";
|
|
1031
|
+
import { getTranslations } from "next-intl/server";
|
|
1032
|
+
|
|
1033
|
+
function localizedPath(locale: string, path: string) {
|
|
1034
|
+
// 如果是默认语言,返回原路径,否则加上语言前缀
|
|
1035
|
+
return locale === defaultLocale ? path : "/" + locale + path;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export async function generateMetadata({
|
|
1039
|
+
params,
|
|
1040
|
+
}: {
|
|
1041
|
+
params: { locale: string };
|
|
1042
|
+
}): Promise<Metadata> {
|
|
1043
|
+
const { locale } = params;
|
|
1044
|
+
// 获取指定语言和命名空间的翻译内容
|
|
1045
|
+
const t = await getTranslations({ locale, namespace: "about" });
|
|
1046
|
+
|
|
1047
|
+
const url = "/about";
|
|
1048
|
+
// 构建所有语言对应的本地化路径对象
|
|
1049
|
+
const languages = Object.fromEntries(
|
|
1050
|
+
locales.map((locale) => [locale, localizedPath(locale, url)])
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
return {
|
|
1054
|
+
title: t("title"), // 页面标题
|
|
1055
|
+
description: t("description"), // 页面描述
|
|
1056
|
+
alternates: {
|
|
1057
|
+
canonical: localizedPath(locale, url), // 规范链接
|
|
1058
|
+
languages: { ...languages, "x-default": url }, // 语言版本链接及默认链接
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ... 页面代码的其余部分
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
```tsx fileName="src/app/sitemap.ts"
|
|
1067
|
+
import type { MetadataRoute } from "next";
|
|
1068
|
+
import { locales, defaultLocale } from "@/i18n";
|
|
1069
|
+
|
|
1070
|
+
const origin = "https://example.com";
|
|
1071
|
+
|
|
1072
|
+
const formatterLocalizedPath = (locale: string, path: string) =>
|
|
1073
|
+
locale === defaultLocale ? origin + path : origin + "/" + locale + path;
|
|
1074
|
+
|
|
1075
|
+
export default function sitemap(): MetadataRoute.Sitemap {
|
|
1076
|
+
const aboutLanguages = Object.fromEntries(
|
|
1077
|
+
locales.map((l) => [l, formatterLocalizedPath(l, "/about")])
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
return [
|
|
1081
|
+
{
|
|
1082
|
+
url: formatterLocalizedPath(defaultLocale, "/about"),
|
|
1083
|
+
lastModified: new Date(),
|
|
1084
|
+
changeFrequency: "monthly",
|
|
1085
|
+
priority: 0.7,
|
|
1086
|
+
alternates: { languages: aboutLanguages },
|
|
1087
|
+
},
|
|
1088
|
+
];
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
```tsx fileName="src/app/robots.ts"
|
|
1093
|
+
import type { MetadataRoute } from "next";
|
|
1094
|
+
import { locales, defaultLocale } from "@/i18n";
|
|
1095
|
+
|
|
1096
|
+
const origin = "https://example.com";
|
|
1097
|
+
const withAllLocales = (path: string) => [
|
|
1098
|
+
path,
|
|
1099
|
+
...locales
|
|
1100
|
+
.filter((locale) => locale !== defaultLocale)
|
|
1101
|
+
.map((locale) => "/" + locale + path),
|
|
1102
|
+
];
|
|
1103
|
+
|
|
1104
|
+
export default function robots(): MetadataRoute.Robots {
|
|
1105
|
+
const disallow = [
|
|
1106
|
+
...withAllLocales("/dashboard"),
|
|
1107
|
+
...withAllLocales("/admin"),
|
|
1108
|
+
];
|
|
1109
|
+
|
|
1110
|
+
return {
|
|
1111
|
+
rules: { userAgent: "*", allow: ["/"], disallow },
|
|
1112
|
+
host: origin,
|
|
1113
|
+
sitemap: origin + "/sitemap.xml",
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
</TabItem>
|
|
1119
|
+
<TabItem label="intlayer" value="intlayer">
|
|
1120
|
+
|
|
1121
|
+
```typescript fileName="src/app/[locale]/about/layout.tsx"
|
|
1122
|
+
import { getIntlayer, getMultilingualUrls } from "intlayer";
|
|
1123
|
+
import type { Metadata } from "next";
|
|
1124
|
+
import type { LocalPromiseParams } from "next-intlayer";
|
|
1125
|
+
|
|
1126
|
+
export const generateMetadata = async ({
|
|
1127
|
+
params,
|
|
1128
|
+
}: LocalPromiseParams): Promise<Metadata> => {
|
|
1129
|
+
const { locale } = await params;
|
|
1130
|
+
|
|
1131
|
+
const metadata = getIntlayer("page-metadata", locale);
|
|
1132
|
+
|
|
1133
|
+
const multilingualUrls = getMultilingualUrls("/about");
|
|
1134
|
+
|
|
1135
|
+
return {
|
|
1136
|
+
...metadata,
|
|
1137
|
+
alternates: {
|
|
1138
|
+
canonical: multilingualUrls[locale as keyof typeof multilingualUrls],
|
|
1139
|
+
languages: { ...multilingualUrls, "x-default": "/about" },
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// ... 页面代码的其余部分
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
```tsx fileName="src/app/sitemap.ts"
|
|
1148
|
+
import { getMultilingualUrls } from "intlayer";
|
|
1149
|
+
import type { MetadataRoute } from "next";
|
|
1150
|
+
|
|
1151
|
+
const sitemap = (): MetadataRoute.Sitemap => [
|
|
1152
|
+
{
|
|
1153
|
+
url: "https://example.com/about",
|
|
1154
|
+
alternates: {
|
|
1155
|
+
languages: { ...getMultilingualUrls("https://example.com/about") },
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
];
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
```tsx fileName="src/app/robots.ts"
|
|
1162
|
+
import { getMultilingualUrls } from "intlayer";
|
|
1163
|
+
import type { MetadataRoute } from "next";
|
|
1164
|
+
|
|
1165
|
+
const getAllMultilingualUrls = (urls: string[]) =>
|
|
1166
|
+
urls.flatMap((url) => Object.values(getMultilingualUrls(url)) as string[]);
|
|
1167
|
+
|
|
1168
|
+
const robots = (): MetadataRoute.Robots => ({
|
|
1169
|
+
rules: {
|
|
1170
|
+
userAgent: "*",
|
|
1171
|
+
allow: ["/"],
|
|
1172
|
+
disallow: getAllMultilingualUrls(["/dashboard"]),
|
|
1173
|
+
},
|
|
1174
|
+
host: "https://example.com",
|
|
1175
|
+
sitemap: "https://example.com/sitemap.xml",
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
export default robots;
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
</TabItem>
|
|
1182
|
+
</Tab>
|
|
1183
|
+
|
|
1184
|
+
> Intlayer 提供了一个 `getMultilingualUrls` 函数,用于为您的站点地图生成多语言 URL。
|
|
1185
|
+
|
|
1186
|
+
---
|
|
1187
|
+
|
|
1188
|
+
---
|
|
1189
|
+
|
|
1190
|
+
## 胜者是…
|
|
1191
|
+
|
|
1192
|
+
这并不简单。每个选项都有权衡。以下是我的看法:
|
|
1193
|
+
|
|
1194
|
+
<Columns>
|
|
1195
|
+
<Column>
|
|
1196
|
+
|
|
1197
|
+
**next-intl**
|
|
1198
|
+
|
|
1199
|
+
- 最简单、轻量,强制你做出的决策较少。如果你想要一个**极简**的解决方案,且你能接受集中式目录,并且你的应用是**小型到中型**。
|
|
1200
|
+
|
|
1201
|
+
</Column>
|
|
1202
|
+
<Column>
|
|
1203
|
+
|
|
1204
|
+
**next-i18next**
|
|
1205
|
+
|
|
1206
|
+
- 成熟、功能丰富,有大量社区插件,但设置成本较高。如果你需要**i18next 的插件生态系统**(例如,通过插件实现高级 ICU 规则),且你的团队已经熟悉 i18next,愿意接受**更多配置**以获得灵活性。
|
|
1207
|
+
|
|
1208
|
+
</Column>
|
|
1209
|
+
<Column>
|
|
1210
|
+
|
|
1211
|
+
**Intlayer**
|
|
1212
|
+
|
|
1213
|
+
- 为现代 Next.js 构建,具有模块化内容、类型安全、工具支持和更少的样板代码。如果你重视**组件范围的内容**、**严格的 TypeScript**、**构建时保证**、**摇树优化**,以及**内置的**路由/SEO/编辑器工具——尤其是针对**Next.js 应用路由器**、设计系统和**大型模块化代码库**。
|
|
1214
|
+
|
|
1215
|
+
</Column>
|
|
1216
|
+
</Columns>
|
|
1217
|
+
|
|
1218
|
+
如果你偏好最小化设置并且能接受一些手动连接,next-intl 是一个不错的选择。如果你需要所有功能且不介意复杂性,next-i18next 也适用。但如果你想要一个现代、可扩展、模块化且带有内置工具的解决方案,Intlayer 旨在为你开箱即用提供这些功能。
|
|
1219
|
+
|
|
1220
|
+
> **企业团队的替代方案**:如果您需要一个经过充分验证的解决方案,能够完美配合像 **Crowdin**、**Phrase** 或其他专业翻译管理系统等成熟的本地化平台,建议考虑具有成熟生态系统和可靠集成的 **next-intl** 或 **next-i18next**。
|
|
1221
|
+
|
|
1222
|
+
> **未来路线图**:Intlayer 还计划开发基于 **i18next** 和 **next-intl** 解决方案的插件。这将使您能够利用 Intlayer 在自动化、语法和内容管理方面的优势,同时保持这些成熟解决方案在应用代码中提供的安全性和稳定性。
|
|
1223
|
+
|
|
1224
|
+
## GitHub STARs
|
|
1225
|
+
|
|
1226
|
+
GitHub 星标是衡量项目受欢迎程度、社区信任度和长期相关性的有力指标。虽然它们不是技术质量的直接衡量标准,但反映了有多少开发者认为该项目有用、关注其进展并可能采用它。对于评估项目价值,星标有助于比较不同选项的吸引力,并提供生态系统增长的洞察。
|
|
146
1227
|
|
|
147
|
-
-
|
|
148
|
-
- **并行保留旧目录**:迁移期间搭桥,避免一次性大迁移。
|
|
149
|
-
- **开启严格检查**:让构建时检测及早暴露缺口。
|
|
150
|
-
- **采用中间件和辅助工具**:全站标准化语言检测和SEO标签。
|
|
151
|
-
- **测量包大小**:随着未使用内容被剔除,预期**包体积减少**。
|
|
1228
|
+
[](https://www.star-history.com/#i18next/next-i18next&amannn/next-intl&aymericzip/intlayer)
|
|
152
1229
|
|
|
153
1230
|
---
|
|
154
1231
|
|
|
155
1232
|
## 结论
|
|
156
1233
|
|
|
157
|
-
|
|
1234
|
+
所有三个库在核心本地化方面都取得了成功。区别在于,在**现代 Next.js**中实现一个健壮且可扩展的设置,你需要付出**多少工作量**:
|
|
158
1235
|
|
|
159
|
-
-
|
|
160
|
-
-
|
|
1236
|
+
- 使用**Intlayer**,**模块化内容**、**严格的 TS**、**构建时安全**、**摇树优化的包**以及**一流的 App Router 和 SEO 工具**都是**默认配置**,而非额外负担。
|
|
1237
|
+
- 如果你的团队重视在多语言、组件驱动的应用中实现**可维护性和速度**,Intlayer 提供了目前**最完整**的体验。
|
|
161
1238
|
|
|
162
1239
|
更多详情请参阅[《为什么选择 Intlayer?》文档](https://intlayer.org/doc/why)。
|