前端如何实现国际化(i18n),并提供一个基于 i18next
和 react-i18next
的非常完善的方案,附带尽可能详细的代码讲解。
为什么选择 i18next
和 react-i18next
?
i18next
: 这是一个功能强大、灵活且与框架无关的国际化核心库。它支持命名空间、复数、上下文、插值、后端加载、缓存、多种语言检测策略等高级特性。它的生态系统非常丰富,有各种插件和工具支持。react-i18next
: 这是i18next
官方提供的 React 绑定库,利用 React 的特性(如 Hooks 和 Context API)使i18next
在 React 应用中的集成变得简单和高效。
这个组合提供了一个成熟、健壮且功能全面的解决方案,适用于从中小型到大型复杂的前端应用。
核心概念
- 资源文件 (Resource Files) : 存储不同语言的翻译文本,通常是 JSON 或 JS 对象格式。
- 初始化 (Initialization) : 配置
i18next
实例,设置默认语言、后备语言、资源、插件等。 - 语言检测 (Language Detection) : 自动检测用户的首选语言(例如,通过浏览器设置、URL 参数、Cookie、LocalStorage 或用户配置)。
- 翻译函数 (Translation Function -
t
) : 获取当前语言对应翻译文本的核心函数。 - 插值 (Interpolation) : 将动态数据嵌入到翻译文本中。
- 复数处理 (Pluralization) : 根据数值自动选择正确的复数形式。
- 命名空间 (Namespaces) : 将翻译资源分割成逻辑块,按需加载,提高性能和可维护性。
- 后端加载 (Backend Loading) : 从服务器或其他来源异步加载翻译资源。
- React 集成 : 使用
I18nextProvider
,useTranslation
Hook,Trans
组件等与 React 组件无缝集成。 - 语言切换: 提供用户界面让用户手动选择语言。
项目结构示例
csharp
my-react-app/
├── public/
│ └── locales/ # 存放翻译文件的公共目录
│ ├── en/ # 英语
│ │ ├── common.json
│ │ └── home.json
│ ├── zh/ # 中文
│ │ ├── common.json
│ │ └── home.json
│ └── fr/ # 法语 (示例)
│ ├── common.json
│ └── home.json
├── src/
│ ├── components/ # React 组件
│ │ ├── Header.js
│ │ ├── LanguageSwitcher.js
│ │ └── ...
│ ├── pages/ # 页面组件
│ │ ├── HomePage.js
│ │ └── ...
│ ├── App.js # 主应用组件
│ ├── i18n.js # i18next 配置文件
│ └── index.js # 应用入口
├── package.json
└── ...
详细代码实现
1. 安装依赖
r
npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector
# 或者
yarn add i18next react-i18next i18next-http-backend i18next-browser-languagedetector
i18next
: 核心库。react-i18next
: React 绑定库。i18next-http-backend
: 用于从服务器/HTTP 端点加载翻译文件的插件。i18next-browser-languagedetector
: 用于在浏览器环境中自动检测语言的插件。
2. 配置 i18next
(src/i18n.js
)
jsx
// src/i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpApi from 'i18next-http-backend';
// --- 配置选项详解 ---
// 语言检测器选项
// 详细文档: https://github.com/i18next/i18next-browser-languageDetector#detector-options
const detectionOptions = {
// 检测顺序,从左到右依次尝试
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],
// 要查找的查询字符串参数名
lookupQuerystring: 'lng',
// 要查找的 cookie 名
lookupCookie: 'i18next',
// 要查找的 localStorage 键名
lookupLocalStorage: 'i18nextLng',
// 要查找的 sessionStorage 键名
lookupSessionStorage: 'i18nextLng',
// 缓存用户选择的语言到哪些地方
caches: ['localStorage', 'cookie'],
excludeCacheFor: ['cimode'], // 'cimode' 是 i18next 的特殊语言,用于开发时查看 key
// Cookie 配置
cookieMinutes: 10, // cookie 有效期(分钟)
cookieDomain: 'myDomain', // 设置 cookie 的域
cookieOptions: { path: '/', sameSite: 'strict' } // 其他 cookie 选项
};
// HTTP 后端选项
// 详细文档: https://github.com/i18next/i18next-http-backend#backend-options
const backendOptions = {
// 翻译文件的加载路径
// '{{lng}}' 会被替换为当前语言代码 (e.g., 'en', 'zh')
// '{{ns}}' 会被替换为命名空间 (e.g., 'common', 'home')
loadPath: '/locales/{{lng}}/{{ns}}.json',
// 如果需要,可以为添加、更新翻译资源设置路径 (通常用于更复杂的场景,如在线编辑)
// addPath: '/locales/add/{{lng}}/{{ns}}',
// 可以自定义请求函数,例如添加认证头
// request: (options, url, payload, callback) => {
// // ... 自定义 fetch 或 XMLHttpRequest 逻辑
// },
// 其他选项如 parse, stringify, ajax, allowMultiLoading 等
};
i18n
// --- 链式调用 ---
// 1. 使用 HTTP 后端加载翻译文件
.use(HttpApi)
// 2. 使用浏览器语言检测器
.use(LanguageDetector)
// 3. 将 i18n 实例传递给 react-i18next
.use(initReactI18next)
// 4. 初始化 i18next
// 详细文档: https://www.i18next.com/overview/configuration-options
.init({
// --- 核心配置 ---
// 支持的语言列表
// 建议明确列出,以便进行验证和管理
supportedLngs: ['en', 'zh', 'fr'],
// 默认语言
// 如果检测不到用户语言或用户语言不受支持,则使用此语言
fallbackLng: 'en',
// 默认加载的命名空间
// 如果不指定,默认为 'translation'
// 可以设置多个默认加载的命名空间
ns: ['common', 'home'],
defaultNS: 'common', // 指定默认使用的命名空间,当调用 t 函数不带命名空间前缀时
// --- 调试与开发 ---
// 在开发环境中开启调试输出
debug: process.env.NODE_ENV === 'development',
// --- 语言检测配置 ---
detection: detectionOptions,
// --- 后端加载配置 ---
backend: backendOptions,
// --- 插值配置 ---
interpolation: {
escapeValue: false, // React 已经内置了 XSS 防护,无需 i18next 再次转义
formatSeparator: ',', // 用于格式化函数的分隔符
format: (value, format, lng) => { // 自定义格式化函数
if (format === 'uppercase') return value.toUpperCase();
if (format === 'lowercase') return value.toLowerCase();
if (value instanceof Date) {
// 使用 Intl API 进行日期格式化,更健壮
try {
return new Intl.DateTimeFormat(lng, getDateFmtOptions(format)).format(value);
} catch (e) {
console.error("Error formatting date:", e);
// 提供一个备用格式
return new Intl.DateTimeFormat(lng).format(value);
}
}
// 可以添加数字格式化等
if (typeof value === 'number' && format?.startsWith('currency')) {
try {
const currencyCode = format.split(':')[1] || 'USD'; // 默认 USD
return new Intl.NumberFormat(lng, { style: 'currency', currency: currencyCode }).format(value);
} catch(e) {
console.error("Error formatting currency:", e);
return new Intl.NumberFormat(lng).format(value); // 备用数字格式
}
}
return value;
},
},
// --- React 集成配置 ---
react: {
// 是否在 Suspense 中包装组件
// 如果你的翻译文件是异步加载的,并且你想在加载时显示 fallback UI,则设为 true
// 需要在你的组件树上层有 <Suspense fallback={...}> 包裹
useSuspense: true,
// Trans 组件默认使用的 HTML 标签
// defaultTransParent: 'div',
// 自定义 Trans 组件的插值行为
// transSupportBasicHtmlNodes: true,
// transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'],
},
// --- 缓存配置 (如果不用 languageDetector 的缓存) ---
// cache: {
// enabled: true,
// prefix: 'i18next_res_',
// expirationTime: 7 * 24 * 60 * 60 * 1000, // 7 days
// store: window.localStorage, // 或者其他存储实现
// },
// --- 资源 (如果不想用后端加载,可以直接内联) ---
// 注意:大型应用不推荐内联所有资源,会导致包体积过大
// resources: {
// en: {
// common: {
// "save": "Save",
// "cancel": "Cancel"
// },
// home: {
// "title": "Welcome Home"
// }
// },
// zh: {
// common: {
// "save": "保存",
// "cancel": "取消"
// },
// home: {
// "title": "欢迎回家"
// }
// }
// }
});
// 辅助函数:根据格式字符串获取日期格式化选项
function getDateFmtOptions(format) {
switch(format) {
case 'short':
return { year: 'numeric', month: 'numeric', day: 'numeric' };
case 'long':
return { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };
case 'time':
return { hour: 'numeric', minute: 'numeric', second: 'numeric' };
case 'full':
return { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'short' };
default:
// 默认返回一个基础格式,或者可以抛出错误
return { year: 'numeric', month: 'numeric', day: 'numeric' };
}
}
export default i18n;
3. 在应用入口导入并初始化 (src/index.js
或 src/main.jsx
)
jsx
// src/index.js
import React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n'; // 导入 i18n 配置文件,执行初始化
const root = ReactDOM.createRoot(document.getElementById('root'));
// 使用 React Suspense 来处理翻译文件加载时的等待状态
// fallback 可以是一个简单的加载指示器或骨架屏
root.render(
<React.StrictMode>
<Suspense fallback={<div>Loading translations...</div>}>
<App />
</Suspense>
</React.StrictMode>
);
4. 创建翻译资源文件
public/locales/en/common.json
:
json
{
"appName": "My Awesome App",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading...",
"error": "An error occurred",
"greeting": "Hello, {{name}}!",
"itemCount": "You have {{count}} item",
"itemCount_plural": "You have {{count}} items",
"friendMessages": "{{count}} message from your friend",
"friendMessages_plural": "{{count}} messages from your friends",
"friendMessages_male": "{{count}} message from your male friend",
"friendMessages_male_plural": "{{count}} messages from your male friends",
"friendMessages_female": "{{count}} message from your female friend",
"friendMessages_female_plural": "{{count}} messages from your female friends",
"userStatus": "User status: {{status, uppercase}}",
"todayIs": "Today is: {{date, long}}",
"price": "Price: {{amount, currency:USD}}"
}
public/locales/zh/common.json
:
json
{
"appName": "我的牛应用",
"save": "保存",
"cancel": "取消",
"loading": "加载中...",
"error": "发生错误",
"greeting": "你好, {{name}}!",
"itemCount": "你有 {{count}} 个项目",
"itemCount_plural": "你有 {{count}} 个项目", // 中文通常单复数形式相同,但 i18next 仍会根据 count 选择
"friendMessages": "来自你朋友的 {{count}} 条消息",
"friendMessages_plural": "来自你朋友们的 {{count}} 条消息",
"friendMessages_male": "来自你男性朋友的 {{count}} 条消息",
"friendMessages_male_plural": "来自你男性朋友们的 {{count}} 条消息",
"friendMessages_female": "来自你女性朋友的 {{count}} 条消息",
"friendMessages_female_plural": "来自你女性朋友们的 {{count}} 条消息",
"userStatus": "用户状态: {{status, uppercase}}",
"todayIs": "今天是: {{date, long}}",
"price": "价格: {{amount, currency:CNY}}" // 注意货币单位变化
}
public/locales/en/home.json
:
json
{
"welcomeTitle": "Welcome to the Home Page!",
"introduction": "This is the main content area of our application.",
"nested": {
"message": "This is a nested message."
},
"htmlContent": "Click <1>here</1> for details. Or <3>learn more</3>.",
"terms": "Please accept the <0>Terms and Conditions</0>"
}
public/locales/zh/home.json
:
json
{
"welcomeTitle": "欢迎来到首页!",
"introduction": "这是我们应用的主要内容区域。",
"nested": {
"message": "这是一条嵌套的消息。"
},
"htmlContent": "点击<1>此处</1>查看详情。或<3>了解更多</3>。",
"terms": "请接受<0>服务条款</0>"
}
5. 在 React 组件中使用
src/components/Header.js
(包含语言切换器)
jsx
// src/components/Header.js
import React from 'react';
import { useTranslation } from 'react-i18next';
import LanguageSwitcher from './LanguageSwitcher'; // 假设我们创建了一个切换器组件
function Header() {
// useTranslation hook
// 参数 'common' 指定了要加载的命名空间
// 如果在 i18n.js 中设置了 defaultNS,可以省略参数,或者传入数组 ['common', 'anotherNamespace']
const { t, i18n } = useTranslation('common');
// 获取应用名称
const appName = t('appName');
return (
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', background: '#eee' }}>
<h1>{appName}</h1>
<nav>
{/* 其他导航链接 */}
</nav>
<LanguageSwitcher /> {/* 放置语言切换器 */}
</header>
);
}
export default Header;
src/components/LanguageSwitcher.js
jsx
// src/components/LanguageSwitcher.js
import React from 'react';
import { useTranslation } from 'react-i18next';
const languages = [
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' },
{ code: 'fr', name: 'Français' },
];
function LanguageSwitcher() {
const { i18n } = useTranslation(); // 只需要 i18n 实例来改变语言
const changeLanguage = (lng) => {
console.log(`Changing language to: ${lng}`);
i18n.changeLanguage(lng)
.then(() => {
console.log(`Language changed successfully to ${lng}`);
// 可以在这里执行语言更改后的回调,例如重新获取特定于语言的数据
})
.catch((err) => {
console.error('Error changing language:', err);
});
};
// 获取当前语言代码,用于高亮显示或其他逻辑
const currentLanguage = i18n.language;
// 注意:i18n.language 可能包含区域代码 (e.g., 'en-US')
// 如果只想匹配基础语言代码 ('en'), 可能需要处理一下
const currentBaseLanguage = currentLanguage.split('-')[0];
return (
<div>
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
disabled={currentBaseLanguage === lang.code} // 禁用当前已选语言的按钮
style={{
margin: '0 5px',
fontWeight: currentBaseLanguage === lang.code ? 'bold' : 'normal',
cursor: currentBaseLanguage === lang.code ? 'default' : 'pointer',
padding: '5px 10px',
border: '1px solid #ccc',
background: currentBaseLanguage === lang.code ? '#ddd' : '#fff',
}}
>
{lang.name}
</button>
))}
</div>
);
}
export default LanguageSwitcher;
src/pages/HomePage.js
(演示各种特性)
jsx
// src/pages/HomePage.js
import React, { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
function HomePage() {
// 加载 'home' 和 'common' 两个命名空间
// t 函数会首先在 'home' 中查找 key,如果找不到,则在 'common' 中查找
// 也可以通过 t('namespace:key') 的方式显式指定命名空间
const { t, i18n } = useTranslation(['home', 'common']);
const [itemCount, setItemCount] = useState(1);
const [genderContext, setGenderContext] = useState('male'); // 'male', 'female', or undefined/null
const userName = "开发者"; // 示例动态数据
const userStatus = "active";
const today = new Date();
const priceAmount = 123.45;
const incrementItems = () => setItemCount(count => count + 1);
const decrementItems = () => setItemCount(count => Math.max(0, count - 1));
return (
<div style={{ padding: '2rem' }}>
{/* 1. 基本翻译 (来自 home 命名空间) */}
<h2>{t('welcomeTitle')}</h2>
{/* 2. 嵌套 Key (来自 home 命名空间) */}
<p>{t('nested.message')}</p>
{/* 3. 插值 (来自 common 命名空间) */}
<p>{t('common:greeting', { name: userName })}</p>
{/* 4. 复数处理 (来自 common 命名空间) */}
<div>
<p>{t('itemCount', { count: itemCount })}</p>
<button onClick={decrementItems}>-</button>
<span style={{ margin: '0 10px' }}>{itemCount}</span>
<button onClick={incrementItems}>+</button>
</div>
{/* 5. 上下文 (Context) + 复数 (来自 common 命名空间) */}
{/* i18next 会根据 genderContext 查找 friendMessages_male 或 friendMessages_female */}
{/* 如果 context 不匹配或未提供,则回退到不带 context 的 key (friendMessages) */}
{/* 然后再根据 itemCount 处理复数 */}
<div style={{ marginTop: '1rem' }}>
<p>{t('friendMessages', { count: itemCount, context: genderContext })}</p>
<label>
Friend's Gender Context:
<select value={genderContext} onChange={(e) => setGenderContext(e.target.value)}>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="">Neutral / Unknown</option>
</select>
</label>
</div>
{/* 6. 格式化 (来自 common 命名空间, 使用 i18n.js 中定义的 format 函数) */}
<p>{t('userStatus', { status: userStatus })}</p>
<p>{t('todayIs', { date: today })}</p>
{/* 使用带参数的格式化 */}
<p>{t('price', { amount: priceAmount })}</p>
{/* 尝试其他货币 */}
{i18n.language.startsWith('zh') && <p>{t('price', { amount: priceAmount, formatParams: { amount: { currency: 'CNY' } } })}</p>}
{i18n.language.startsWith('en') && <p>{t('price', { amount: priceAmount, formatParams: { amount: { currency: 'USD' } } })}</p>}
{i18n.language.startsWith('fr') && <p>{t('price', { amount: priceAmount, formatParams: { amount: { currency: 'EUR' } } })}</p>}
{/* 7. 使用 Trans 组件处理包含 HTML 或 React 组件的翻译 */}
{/* (来自 home 命名空间) */}
<p>
<Trans i18nKey="home:htmlContent">
Click <a href="/details">here</a> for details. Or <button onClick={() => alert('Learned more!')}>learn more</button>.
</Trans>
</p>
{/* Trans 组件的另一种用法,将组件作为值传入 */}
<p>
<Trans i18nKey="home:terms">
Please accept the <a href="/terms" style={{color: 'blue', textDecoration: 'underline'}}>Terms and Conditions</a>
</Trans>
</p>
{/* 8. 从 common 命名空间获取文本 */}
<button style={{ marginTop: '1rem', marginRight: '0.5rem' }}>{t('common:save')}</button>
<button style={{ marginTop: '1rem' }}>{t('common:cancel')}</button>
{/* 9. 处理 Key 不存在的情况 */}
{/* i18next 默认会返回 key 本身 */}
<p style={{ marginTop: '1rem', color: 'red' }}>Missing key example: {t('aMissingKey')}</p>
{/* 也可以提供默认值 */}
<p style={{ marginTop: '1rem', color: 'orange' }}>Missing key with default: {t('anotherMissingKey', 'This is a default value.')}</p>
</div>
);
}
export default HomePage;
src/App.js
(组装应用)
jsx
// src/App.js
import React from 'react';
import Header from './components/Header';
import HomePage from './pages/HomePage';
import { useTranslation } from 'react-i18next'; // 可以在 App 级别使用
function App() {
const { t } = useTranslation('common'); // 可以在顶层加载通用翻译
return (
<div className="App">
<Header />
<main>
{/* 这里可以添加路由逻辑 */}
<HomePage />
</main>
<footer style={{ padding: '1rem', marginTop: '2rem', background: '#f8f8f8', textAlign: 'center' }}>
<p>© {new Date().getFullYear()} {t('appName')}</p>
</footer>
</div>
);
}
export default App;
进阶主题与最佳实践
-
翻译管理:
- 对于大型项目,手动管理 JSON 文件可能变得困难。考虑使用专业的翻译管理平台(TMS),如 Locize (由 i18next 作者开发,深度集成)、Phrase, Crowdin, Lokalise 等。这些平台提供 UI 让翻译人员协作,并能通过 API 或 CLI 与开发流程集成。
i18next-locize-backend
插件可以直接连接 Locize。
-
文本提取:
-
使用工具自动扫描代码库,提取需要翻译的文本(通常是
t()
函数的参数或Trans
组件的内容),生成基础翻译文件。 -
常用的工具有
i18next-parser
(JavaScript/TypeScript) 或特定框架的插件。 -
示例
i18next-parser
配置 (i18next-parser.config.js
):javamodule.exports = { contextSeparator: '_', // Key 分隔符 keySeparator: '.', // 在代码中查找的函数名和属性名 lexers: { js: ['JsxLexer'], // 使用 JsxLexer 处理 JSX ts: ['JsxLexer'], jsx: ['JsxLexer'], tsx: ['JsxLexer'], default: ['JavascriptLexer'], }, // 输出路径,{{lng}} 和 {{ns}} 会被替换 locales: ['en', 'zh', 'fr'], // 目标语言 // 命名空间 namespaceSeparator: ':', // 输出路径 output: 'public/locales/{{lng}}/{{ns}}.json', // 输入文件或目录 input: ['src/**/*.{js,jsx,ts,tsx}'], // 默认命名空间 defaultNamespace: 'common', // 如果 key 中未使用命名空间,则添加到默认命名空间 useKeysAsDefaultValue: true, // 将提取的 key 作为默认值(通常是英文) verbose: true, // 显示详细输出 };
然后在
package.json
中添加脚本:"extract": "i18next-parser --config i18next-parser.config.js"
-
-
命名空间懒加载:
- 对于大型应用,一次性加载所有命名空间可能影响初始加载性能。
react-i18next
的useTranslation
Hook 默认支持懒加载。当你传入命名空间参数时,如果该命名空间尚未加载,它会触发加载(需要后端插件支持)。结合 React Suspense,可以在命名空间加载时显示 fallback UI。 - 确保
i18n.js
中react.useSuspense
设置为true
,并在组件树中使用<Suspense>
。
- 对于大型应用,一次性加载所有命名空间可能影响初始加载性能。
-
测试:
- 单元测试/集成测试需要模拟或提供
i18next
实例。react-i18next
提供了一些测试工具函数,或者你可以简单地在测试设置中初始化一个包含测试数据的i18n
实例。 - 可以使用
jest.mock
来模拟react-i18next
的useTranslation
Hook。
- 单元测试/集成测试需要模拟或提供
-
SSR (服务器端渲染) :
- 在 SSR 环境下,语言检测需要在服务器端进行(通常基于请求头
Accept-Language
或 Cookie)。 - 需要确保在服务器端预加载正确的语言和命名空间,并将初始的 i18n 状态和资源传递给客户端(Hydration),避免客户端重新检测和加载。
i18next-fs-backend
可以在 Node.js 环境中从文件系统加载资源。- 框架如 Next.js 有特定的 i18n 集成方案,通常会包装
i18next
。
- 在 SSR 环境下,语言检测需要在服务器端进行(通常基于请求头
-
RTL (从右到左) 语言支持:
- 国际化不仅是文本翻译,还包括布局适应。对于阿拉伯语、希伯来语等 RTL 语言,需要调整 CSS。
- 可以通过在
<html>
或<body>
标签上添加dir="rtl"
属性(可以根据i18n.dir()
方法判断当前语言方向),然后编写相应的 RTL CSS 规则(例如,使用margin-left
代替margin-right
,或者使用逻辑属性如margin-inline-start
)。
-
性能优化:
- 合理划分命名空间。
- 使用
i18next-http-backend
的缓存机制或i18next-localstorage-cache
插件。 - 仅加载当前需要的语言和命名空间。
这个方案结合了 i18next
的强大功能和 react-i18next
的易用性,提供了一个可扩展、可维护且功能完善的前端国际化解决方案。通过理解这些核心概念和实践,你可以为你的应用构建出色的多语言体验。