前端国际化方案 (i18n)
多语言应用开发的完整方案,从技术选型到工程化实践
目录
核心概念
i18n vs l10n
| 概念 | 全称 | 说明 | 示例 |
|---|---|---|---|
| i18n | Internationalization | 国际化架构设计 | 文本分离、占位符 |
| l10n | Localization | 本地化内容翻译 | 中文翻译、日期格式 |
国际化涉及的维度
┌─────────────────────────────────────────────────────────┐
│ 国际化内容 │
├───────────────┬───────────────┬─────────────────────────┤
│ 文本翻译 │ 格式本地化 │ 文化适配 │
├───────────────┼───────────────┼─────────────────────────┤
│ • 静态文案 │ • 日期时间 │ • 阅读方向 (RTL) │
│ • 动态消息 │ • 数字货币 │ • 颜色含义 │
│ • 错误提示 │ • 排序规则 │ • 图标图片 │
│ • 复数形式 │ • 时区 │ • 法律合规 │
└───────────────┴───────────────┴─────────────────────────┘技术方案对比
| 库 | 框架 | 特点 | Bundle Size |
|---|---|---|---|
| react-i18next | React | 功能全面、生态丰富 | ~40KB |
| react-intl (FormatJS) | React | ICU 标准、TypeScript 友好 | ~50KB |
| vue-i18n | Vue | Vue 官方推荐 | ~30KB |
| next-intl | Next.js | App Router 原生支持 | ~15KB |
| Intl API | 原生 | 零依赖、格式化专用 | 0 |
React i18n 实践
react-i18next 配置
typescript
// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import zh from './locales/zh.json';
i18n
.use(LanguageDetector) // 自动检测语言
.use(initReactI18next) // React 绑定
.init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
fallbackLng: 'en', // 回退语言
interpolation: {
escapeValue: false // React 已做 XSS 防护
}
});
export default i18n;翻译文件结构
json
// locales/en.json
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel"
},
"greeting": "Hello, {{name}}!",
"items": "{{count}} item",
"items_plural": "{{count}} items"
}
// locales/zh.json
{
"common": {
"confirm": "确认",
"cancel": "取消"
},
"greeting": "你好,{{name}}!",
"items": "{{count}} 个项目"
}组件使用
tsx
import { useTranslation, Trans } from 'react-i18next';
function MyComponent() {
const { t, i18n } = useTranslation();
return (
<div>
{/* 基础翻译 */}
<button>{t('common.confirm')}</button>
{/* 插值 */}
<p>{t('greeting', { name: 'John' })}</p>
{/* 复数 */}
<p>{t('items', { count: 5 })}</p>
{/* 包含 JSX 的翻译 */}
<Trans i18nKey="welcome">
Welcome to <strong>our app</strong>
</Trans>
{/* 切换语言 */}
<button onClick={() => i18n.changeLanguage('zh')}>
中文
</button>
</div>
);
}动态加载翻译文件
typescript
// 按需加载,减少首屏体积
import i18n from 'i18next';
import Backend from 'i18next-http-backend';
i18n
.use(Backend)
.init({
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json'
},
ns: ['common', 'home', 'settings'],
defaultNS: 'common'
});Intl API
日期格式化
javascript
const date = new Date();
// 基础格式化
new Intl.DateTimeFormat('zh-CN').format(date);
// "2024/3/15"
new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
// "Friday, March 15, 2024"
// 相对时间
const rtf = new Intl.RelativeTimeFormat('zh', { numeric: 'auto' });
rtf.format(-1, 'day'); // "昨天"
rtf.format(2, 'hour'); // "2小时后"数字与货币
javascript
// 数字格式化
new Intl.NumberFormat('de-DE').format(1234567.89);
// "1.234.567,89"
// 货币格式化
new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY'
}).format(1000);
// "¥1,000"
// 百分比
new Intl.NumberFormat('en', {
style: 'percent',
minimumFractionDigits: 1
}).format(0.256);
// "25.6%"
// 紧凑表示
new Intl.NumberFormat('en', {
notation: 'compact',
compactDisplay: 'short'
}).format(1500000);
// "1.5M"复数规则
javascript
const pr = new Intl.PluralRules('en');
pr.select(0); // "other"
pr.select(1); // "one"
pr.select(2); // "other"
const prRu = new Intl.PluralRules('ru');
prRu.select(1); // "one"
prRu.select(2); // "few"
prRu.select(5); // "many"列表格式化
javascript
const lf = new Intl.ListFormat('en', {
style: 'long',
type: 'conjunction'
});
lf.format(['Apple', 'Banana', 'Orange']);
// "Apple, Banana, and Orange"
const lfZh = new Intl.ListFormat('zh', { type: 'disjunction' });
lfZh.format(['苹果', '香蕉', '橙子']);
// "苹果、香蕉或橙子"工程化
翻译文件组织
src/
├── locales/
│ ├── en/
│ │ ├── common.json
│ │ ├── home.json
│ │ └── settings.json
│ └── zh/
│ ├── common.json
│ ├── home.json
│ └── settings.json提取翻译 Key
bash
# 使用 i18next-parser 自动提取
npx i18next-parser 'src/**/*.{ts,tsx}'javascript
// i18next-parser.config.js
module.exports = {
locales: ['en', 'zh'],
output: 'src/locales/$LOCALE/$NAMESPACE.json',
input: ['src/**/*.{ts,tsx}'],
defaultNamespace: 'common',
keySeparator: '.',
namespaceSeparator: ':'
};TypeScript 类型安全
typescript
// i18n.d.ts
import 'i18next';
import en from './locales/en/common.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof en;
};
}
}CI 检查
yaml
# .github/workflows/i18n-check.yml
name: i18n Check
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npm run i18n:extract
- run: |
if [ -n "$(git diff --name-only)" ]; then
echo "Missing translations detected!"
git diff
exit 1
fi高频面试题
Q1: 如何处理复数形式?
ICU MessageFormat 语法:
json
{
"items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}不同语言复数规则不同(英语 2 种、俄语 4 种、阿拉伯语 6 种),使用 Intl.PluralRules 或库内置支持。
Q2: 如何实现 RTL (从右到左) 布局?
css
/* 使用逻辑属性 */
.container {
/* 替代 padding-left */
padding-inline-start: 16px;
/* 替代 margin-right */
margin-inline-end: 8px;
}
/* 根据 dir 属性切换 */
[dir="rtl"] .icon {
transform: scaleX(-1);
}html
<html dir="rtl" lang="ar">Q3: 翻译文件过大如何优化?
- 按路由拆分:每个页面独立 namespace
- 动态加载:
i18next-http-backend按需请求 - Tree-shaking:编译时移除未使用的 key
- CDN 缓存:翻译文件单独部署
Q4: 如何保证翻译完整性?
- 自动提取:CI/CD 中运行
i18next-parser - 缺失检测:比较各语言文件的 key 一致性
- 类型检查:TypeScript 类型推断
- 翻译管理平台:Crowdin、Lokalise、Phrase
最佳实践
- [ ] Key 使用语义化命名(
form.validation.required) - [ ] 避免拼接字符串,使用插值
- [ ] 复数和性别使用 ICU MessageFormat
- [ ] 日期/货币使用 Intl API
- [ ] CI 中检查翻译完整性
- [ ] 考虑 RTL 布局支持