前端国际化(i18n)完整方案:从基础配置到生产最佳实践
随着业务全球化,国际化(i18n)成为现代前端应用的标配。一个完善的国际化方案不仅要处理文本翻译,还需要考虑日期时间、数字货币、复数规则、RTL 布局等多方面因素。本文将详细讲解如何构建企业级的国际化解决方案。
国际化的核心概念
术语解释
- i18n (Internationalization):国际化,指设计和开发能够适应不同语言和地区的应用
- L10n (Localization):本地化,指将应用适配到特定语言和地区
- Locale:地区设置,通常格式为
语言-地区,如zh-CN、en-US
需要国际化的内容
| 类型 | 示例 | 处理方式 |
|---|---|---|
| 文本内容 | 按钮文案、提示信息 | 翻译文件 |
| 日期时间 | 2024年1月1日 / Jan 1, 2024 | Intl.DateTimeFormat |
| 数字格式 | 1,234.56 / 1.234,56 | Intl.NumberFormat |
| 货币 | ¥100 / $100 | Intl.NumberFormat |
| 复数规则 | 1 item / 2 items | ICU 复数语法 |
| 排序规则 | 字母/拼音排序 | Intl.Collator |
| 文字方向 | LTR/RTL | CSS logical properties |
React 国际化方案
react-i18next 配置
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend
// i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
// 加载翻译文件
.use(Backend)
// 自动检测语言
.use(LanguageDetector)
// React 绑定
.use(initReactI18next)
.init({
// 默认语言
fallbackLng: 'en',
// 支持的语言
supportedLngs: ['en', 'zh-CN', 'zh-TW', 'ja', 'ko'],
// 调试模式
debug: process.env.NODE_ENV === 'development',
// 命名空间
ns: ['common', 'auth', 'dashboard', 'errors'],
defaultNS: 'common',
// 后端配置
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
// 检测配置
detection: {
// 检测顺序
order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
// 缓存位置
caches: ['localStorage', 'cookie'],
// URL 参数名
lookupQuerystring: 'lang',
// Cookie 名
lookupCookie: 'i18next',
// LocalStorage Key
lookupLocalStorage: 'i18nextLng',
},
// 插值配置
interpolation: {
escapeValue: false, // React 已经处理 XSS
formatSeparator: ',',
},
// React 配置
react: {
useSuspense: true,
bindI18n: 'languageChanged',
bindI18nStore: '',
},
});
export default i18n;
翻译文件结构
public/
└── locales/
├── en/
│ ├── common.json
│ ├── auth.json
│ └── dashboard.json
├── zh-CN/
│ ├── common.json
│ ├── auth.json
│ └── dashboard.json
└── ja/
├── common.json
├── auth.json
└── dashboard.json
// locales/zh-CN/common.json
{
"nav": {
"home": "首页",
"products": "产品",
"about": "关于我们",
"contact": "联系我们"
},
"actions": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"confirm": "确认",
"submit": "提交"
},
"messages": {
"loading": "加载中...",
"saving": "保存中...",
"success": "操作成功",
"error": "操作失败,请重试"
},
"validation": {
"required": "此字段为必填项",
"email": "请输入有效的邮箱地址",
"minLength": "最少输入 {{min}} 个字符",
"maxLength": "最多输入 {{max}} 个字符"
}
}
// locales/en/common.json
{
"nav": {
"home": "Home",
"products": "Products",
"about": "About Us",
"contact": "Contact"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"confirm": "Confirm",
"submit": "Submit"
},
"messages": {
"loading": "Loading...",
"saving": "Saving...",
"success": "Operation successful",
"error": "Operation failed, please try again"
},
"validation": {
"required": "This field is required",
"email": "Please enter a valid email address",
"minLength": "Enter at least {{min}} characters",
"maxLength": "Enter at most {{max}} characters"
}
}
组件中使用
// 使用 useTranslation Hook
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t, i18n } = useTranslation();
return (
<div>
{/* 基础翻译 */}
<h1>{t('nav.home')}</h1>
{/* 带插值 */}
<p>{t('validation.minLength', { min: 6 })}</p>
{/* 切换语言 */}
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
<option value="ja">日本語</option>
</select>
</div>
);
}
// 使用特定命名空间
function AuthPage() {
const { t } = useTranslation('auth');
return (
<form>
<h1>{t('login.title')}</h1>
<button>{t('login.submit')}</button>
</form>
);
}
// 多命名空间
function Dashboard() {
const { t } = useTranslation(['dashboard', 'common']);
return (
<div>
<h1>{t('dashboard:title')}</h1>
<button>{t('common:actions.save')}</button>
</div>
);
}
Trans 组件处理富文本
import { Trans } from 'react-i18next';
// 翻译文件
// "welcome": "欢迎 <strong>{{name}}</strong>,您有 <link>{{count}} 条新消息</link>"
function Welcome({ name, messageCount }) {
return (
<Trans
i18nKey="welcome"
values={{ name, count: messageCount }}
components={{
strong: <strong className="font-bold" />,
link: <a href="/messages" className="text-blue-500" />
}}
/>
);
}
复数与 ICU 语法
复数规则
不同语言的复数规则差异很大:
// en/common.json
{
"items": {
"zero": "No items",
"one": "{{count}} item",
"other": "{{count}} items"
},
"messages_unread": "You have {{count}} unread message",
"messages_unread_plural": "You have {{count}} unread messages"
}
// zh-CN/common.json (中文没有复数变化)
{
"items": "{{count}} 个项目",
"messages_unread": "您有 {{count}} 条未读消息"
}
// ar/common.json (阿拉伯语有复杂复数规则)
{
"items": {
"zero": "لا عناصر",
"one": "عنصر واحد",
"two": "عنصران",
"few": "{{count}} عناصر",
"many": "{{count}} عنصرًا",
"other": "{{count}} عنصر"
}
}
// 使用复数
function ItemCount({ count }) {
const { t } = useTranslation();
return (
<span>
{t('items', { count })}
</span>
);
}
ICU MessageFormat
更复杂的条件翻译使用 ICU 格式:
{
"greeting": "{gender, select, male {他} female {她} other {Ta}} 说了 {message}",
"notification": "{count, plural, =0 {没有通知} =1 {1 条通知} other {# 条通知}}",
"status": "{status, select, pending {等待中} approved {已批准} rejected {已拒绝} other {未知状态}}"
}
// 需要安装 i18next-icu
import ICU from 'i18next-icu';
i18n
.use(ICU)
.use(initReactI18next)
.init({
// ...
});
日期时间与数字格式化
使用 Intl API
// utils/format.ts
export function formatDate(
date: Date,
locale: string,
options?: Intl.DateTimeFormatOptions
): string {
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};
return new Intl.DateTimeFormat(locale, { ...defaultOptions, ...options }).format(date);
}
export function formatNumber(
num: number,
locale: string,
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(locale, options).format(num);
}
export function formatCurrency(
amount: number,
locale: string,
currency: string
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
export function formatRelativeTime(
date: Date,
locale: string
): string {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (Math.abs(diffInSeconds) < 60) {
return rtf.format(-diffInSeconds, 'second');
}
if (Math.abs(diffInSeconds) < 3600) {
return rtf.format(-Math.floor(diffInSeconds / 60), 'minute');
}
if (Math.abs(diffInSeconds) < 86400) {
return rtf.format(-Math.floor(diffInSeconds / 3600), 'hour');
}
return rtf.format(-Math.floor(diffInSeconds / 86400), 'day');
}
封装成 Hook
// hooks/useFormat.ts
import { useTranslation } from 'react-i18next';
import { formatDate, formatNumber, formatCurrency, formatRelativeTime } from '@/utils/format';
export function useFormat() {
const { i18n } = useTranslation();
const locale = i18n.language;
return {
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
formatDate(date, locale, options),
number: (num: number, options?: Intl.NumberFormatOptions) =>
formatNumber(num, locale, options),
currency: (amount: number, currency = 'CNY') =>
formatCurrency(amount, locale, currency),
relativeTime: (date: Date) =>
formatRelativeTime(date, locale),
percent: (num: number) =>
formatNumber(num, locale, { style: 'percent' }),
};
}
// 使用
function ProductPrice({ price, originalPrice }) {
const { currency, percent } = useFormat();
const discount = (originalPrice - price) / originalPrice;
return (
<div>
<span className="text-red-500">{currency(price)}</span>
<span className="line-through text-gray-400">{currency(originalPrice)}</span>
<span className="text-green-500">-{percent(discount)}</span>
</div>
);
}
RTL (从右到左) 支持
检测和应用 RTL
// utils/rtl.ts
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
export function isRTL(locale: string): boolean {
return RTL_LOCALES.some(rtl => locale.startsWith(rtl));
}
// App.tsx
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
import { isRTL } from '@/utils/rtl';
function App() {
const { i18n } = useTranslation();
useEffect(() => {
const dir = isRTL(i18n.language) ? 'rtl' : 'ltr';
document.documentElement.dir = dir;
document.documentElement.lang = i18n.language;
}, [i18n.language]);
return <Router />;
}
CSS Logical Properties
使用逻辑属性自动适配 RTL:
/* 传统方式(不适配 RTL) */
.card {
margin-left: 16px;
padding-right: 24px;
text-align: left;
border-left: 2px solid blue;
}
/* 逻辑属性(自动适配 RTL) */
.card {
margin-inline-start: 16px; /* LTR: margin-left, RTL: margin-right */
padding-inline-end: 24px; /* LTR: padding-right, RTL: padding-left */
text-align: start; /* LTR: left, RTL: right */
border-inline-start: 2px solid blue;
}
/* 完整的逻辑属性映射 */
.element {
/* 内联方向(水平) */
margin-inline-start: 10px; /* margin-left */
margin-inline-end: 10px; /* margin-right */
padding-inline: 10px; /* padding-left + padding-right */
/* 块方向(垂直) */
margin-block-start: 10px; /* margin-top */
margin-block-end: 10px; /* margin-bottom */
padding-block: 10px; /* padding-top + padding-bottom */
/* 尺寸 */
inline-size: 100px; /* width */
block-size: 100px; /* height */
/* 定位 */
inset-inline-start: 0; /* left */
inset-inline-end: 0; /* right */
inset-block-start: 0; /* top */
inset-block-end: 0; /* bottom */
/* 边框和圆角 */
border-inline-start: 1px solid;
border-start-start-radius: 4px; /* border-top-left-radius */
border-start-end-radius: 4px; /* border-top-right-radius */
}
Tailwind CSS RTL 支持
// tailwind.config.js
module.exports = {
plugins: [
require('tailwindcss-rtl'),
],
}
<!-- 使用 RTL 变体 -->
<div class="ms-4 me-2 ps-3 pe-1">
<!-- ms = margin-inline-start, me = margin-inline-end -->
<!-- ps = padding-inline-start, pe = padding-inline-end -->
</div>
<div class="text-start float-start">
<!-- text-start 在 RTL 下变成 text-right -->
</div>
<!-- 条件样式 -->
<div class="ltr:pl-4 rtl:pr-4">
<!-- LTR 时 padding-left,RTL 时 padding-right -->
</div>
翻译工作流管理
翻译文件自动提取
npm install -D i18next-parser
// i18next-parser.config.js
module.exports = {
locales: ['en', 'zh-CN', 'ja'],
output: 'public/locales/$LOCALE/$NAMESPACE.json',
input: ['src/**/*.{ts,tsx}'],
// 保留已有翻译
keepRemoved: false,
// Key 分隔符
keySeparator: '.',
namespaceSeparator: ':',
// 默认值
defaultValue: (locale, namespace, key) => {
if (locale === 'en') {
// 英文用 key 作为默认值
return key.split('.').pop();
}
return '';
},
// 排序
sort: true,
// 输出格式
indentation: 2,
};
// package.json
{
"scripts": {
"i18n:extract": "i18next-parser",
"i18n:check": "i18next-parser --fail-on-warnings"
}
}
翻译状态追踪
// scripts/check-translations.ts
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
const LOCALES_DIR = './public/locales';
const BASE_LOCALE = 'en';
function getTranslationKeys(locale: string, namespace: string): Set<string> {
const filePath = join(LOCALES_DIR, locale, `${namespace}.json`);
try {
const content = JSON.parse(readFileSync(filePath, 'utf-8'));
return new Set(flattenKeys(content));
} catch {
return new Set();
}
}
function flattenKeys(obj: any, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
return flattenKeys(value, fullKey);
}
return [fullKey];
});
}
function checkTranslations() {
const locales = readdirSync(LOCALES_DIR);
const namespaces = readdirSync(join(LOCALES_DIR, BASE_LOCALE))
.map(f => f.replace('.json', ''));
const report: Record<string, { missing: string[]; extra: string[] }> = {};
for (const locale of locales) {
if (locale === BASE_LOCALE) continue;
report[locale] = { missing: [], extra: [] };
for (const ns of namespaces) {
const baseKeys = getTranslationKeys(BASE_LOCALE, ns);
const localeKeys = getTranslationKeys(locale, ns);
// 找出缺失的 key
for (const key of baseKeys) {
if (!localeKeys.has(key)) {
report[locale].missing.push(`${ns}:${key}`);
}
}
// 找出多余的 key
for (const key of localeKeys) {
if (!baseKeys.has(key)) {
report[locale].extra.push(`${ns}:${key}`);
}
}
}
}
// 输出报告
console.log('\n📊 Translation Status Report\n');
for (const [locale, { missing, extra }] of Object.entries(report)) {
const total = getTranslationKeys(BASE_LOCALE, 'common').size;
const translated = total - missing.length;
const percentage = ((translated / total) * 100).toFixed(1);
console.log(`${locale}: ${percentage}% complete`);
if (missing.length > 0) {
console.log(` ⚠️ Missing: ${missing.length} keys`);
missing.slice(0, 5).forEach(key => console.log(` - ${key}`));
if (missing.length > 5) {
console.log(` ... and ${missing.length - 5} more`);
}
}
if (extra.length > 0) {
console.log(` ℹ️ Extra: ${extra.length} keys`);
}
console.log('');
}
}
checkTranslations();
性能优化
按需加载翻译
// i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
i18n
.use(Backend)
.use(initReactI18next)
.init({
// 只预加载必要的命名空间
ns: ['common'],
defaultNS: 'common',
// 懒加载其他命名空间
partialBundledLanguages: true,
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
// 添加缓存
requestOptions: {
cache: 'default',
},
},
});
// 动态加载命名空间
import { useTranslation } from 'react-i18next';
import { Suspense } from 'react';
function LazyDashboard() {
// 自动加载 dashboard 命名空间
const { t, ready } = useTranslation('dashboard', { useSuspense: false });
if (!ready) {
return <Loading />;
}
return <div>{t('title')}</div>;
}
// 或使用 Suspense
function App() {
return (
<Suspense fallback={<Loading />}>
<LazyDashboard />
</Suspense>
);
}
预加载关键语言
// 预加载最可能用到的语言
const userLocale = navigator.language;
const supportedLocales = ['en', 'zh-CN', 'ja'];
const preloadLocales = supportedLocales.filter(
locale => locale === userLocale || locale.startsWith(userLocale.split('-')[0])
);
preloadLocales.forEach(locale => {
// 预加载翻译文件
fetch(`/locales/${locale}/common.json`);
});
总结
前端国际化需要考虑:
- 翻译管理:结构化的翻译文件,自动提取工具
- 复数处理:ICU 格式支持不同语言的复数规则
- 格式化:日期、数字、货币的本地化格式
- RTL 支持:CSS 逻辑属性适配从右到左语言
- 性能优化:按需加载翻译文件
- 工作流:翻译状态追踪,CI/CD 集成
完善的国际化方案能让应用快速拓展到全球市场。
延伸阅读
- Nuxt i18n 多语言方案 - Nuxt 框架的国际化实践
- 构建多语言官网系统 - 完整的多语言网站案例


