前端国际化 (i18n)
概述
国际化 (Internationalization, i18n) 是指设计和开发软件使其能够适应不同语言和地区的过程。本章节介绍前端国际化的实现方案和最佳实践。
一、国际化核心概念
1. 基本术语
javascript
/**
* i18n - Internationalization (国际化)
* i 和 n 之间有 18 个字母
* 指设计软件使其能适应不同语言
*
* l10n - Localization (本地化)
* l 和 n 之间有 10 个字母
* 指将软件翻译成特定语言
*
* Locale - 地区设置
* 包含语言、地区、格式偏好
* 格式: language-REGION (如 zh-CN, en-US)
*/2. 需要国际化的内容
| 类型 | 示例 |
|---|---|
| 文本 | 界面文字、提示信息、错误消息 |
| 日期时间 | 2024-01-15 vs 01/15/2024 |
| 数字 | 1,234.56 vs 1.234,56 |
| 货币 | $100 vs ¥100 vs €100 |
| 排序规则 | 中文按拼音、日文按假名 |
| 方向 | LTR (左到右) vs RTL (右到左) |
| 图片 | 包含文字的图片需要替换 |
| 颜色 | 不同文化对颜色含义不同 |
二、React 国际化 (react-i18next)
1. 安装配置
bash
npm install react-i18next i18next i18next-browser-languagedetectorjavascript
// i18n/index.js
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enUS from './locales/en-US.json'
import zhCN from './locales/zh-CN.json'
i18n
.use(LanguageDetector) // 自动检测语言
.use(initReactI18next) // 绑定 React
.init({
resources: {
'en-US': { translation: enUS },
'zh-CN': { translation: zhCN }
},
fallbackLng: 'en-US', // 回退语言
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false // React 已经转义
},
detection: {
// 检测顺序
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
// URL 参数名
lookupQuerystring: 'lang',
// cookie 名
lookupCookie: 'i18next',
// localStorage key
lookupLocalStorage: 'i18nextLng',
// 缓存到
caches: ['localStorage', 'cookie']
}
})
export default i18n2. 语言文件
json
// locales/en-US.json
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"loading": "Loading..."
},
"header": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"user": {
"welcome": "Welcome, {{name}}!",
"profile": "User Profile",
"logout": "Logout"
},
"validation": {
"required": "This field is required",
"email": "Please enter a valid email",
"minLength": "Minimum {{min}} characters required"
},
"items": {
"count_one": "{{count}} item",
"count_other": "{{count}} items"
}
}
// locales/zh-CN.json
{
"common": {
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"loading": "加载中..."
},
"header": {
"home": "首页",
"about": "关于",
"contact": "联系我们"
},
"user": {
"welcome": "欢迎,{{name}}!",
"profile": "用户资料",
"logout": "退出登录"
},
"validation": {
"required": "此字段必填",
"email": "请输入有效的邮箱地址",
"minLength": "最少需要 {{min}} 个字符"
},
"items": {
"count_one": "{{count}} 个项目",
"count_other": "{{count}} 个项目"
}
}3. 基本使用
jsx
// App.jsx
import { useTranslation } from 'react-i18next'
import './i18n'
function App() {
const { t, i18n } = useTranslation()
const changeLanguage = (lng) => {
i18n.changeLanguage(lng)
}
return (
<div>
{/* 语言切换 */}
<select
value={i18n.language}
onChange={(e) => changeLanguage(e.target.value)}
>
<option value="en-US">English</option>
<option value="zh-CN">中文</option>
</select>
{/* 基本翻译 */}
<h1>{t('header.home')}</h1>
{/* 带变量 */}
<p>{t('user.welcome', { name: 'John' })}</p>
{/* 复数 */}
<p>{t('items.count', { count: 1 })}</p>
<p>{t('items.count', { count: 5 })}</p>
{/* 嵌套使用 */}
<button>{t('common.save')}</button>
</div>
)
}
// 使用 Trans 组件处理复杂场景
import { Trans } from 'react-i18next'
function RichText() {
return (
<Trans i18nKey="richText">
Click <a href="/terms">here</a> to read our terms.
</Trans>
)
}4. 命名空间
javascript
// 配置命名空间
i18n.init({
ns: ['common', 'user', 'product'],
defaultNS: 'common',
resources: {
'en-US': {
common: { /* ... */ },
user: { /* ... */ },
product: { /* ... */ }
}
}
})
// 使用
const { t } = useTranslation('user') // 指定命名空间
t('profile') // user:profile
const { t } = useTranslation(['user', 'common']) // 多命名空间
t('user:profile')
t('common:save')5. 懒加载语言包
javascript
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import Backend from 'i18next-http-backend'
i18n
.use(Backend)
.use(initReactI18next)
.init({
fallbackLng: 'en-US',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json'
},
ns: ['common'],
defaultNS: 'common'
})
// 按需加载命名空间
i18n.loadNamespaces('product').then(() => {
// product 命名空间已加载
})
// Suspense 支持
import { Suspense } from 'react'
function App() {
return (
<Suspense fallback="Loading...">
<MyComponent />
</Suspense>
)
}三、Vue 国际化 (vue-i18n)
1. 安装配置
bash
npm install vue-i18n@9javascript
// i18n/index.js
import { createI18n } from 'vue-i18n'
import enUS from './locales/en-US.json'
import zhCN from './locales/zh-CN.json'
const i18n = createI18n({
legacy: false, // 使用 Composition API
locale: localStorage.getItem('locale') || 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'en-US': enUS,
'zh-CN': zhCN
},
// 日期时间格式
datetimeFormats: {
'en-US': {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
},
'zh-CN': {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
}
},
// 数字格式
numberFormats: {
'en-US': {
currency: { style: 'currency', currency: 'USD' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
},
'zh-CN': {
currency: { style: 'currency', currency: 'CNY' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
}
}
})
export default i18n
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n'
createApp(App).use(i18n).mount('#app')2. 基本使用
vue
<template>
<div>
<!-- 语言切换 -->
<select v-model="locale">
<option value="en-US">English</option>
<option value="zh-CN">中文</option>
</select>
<!-- 基本翻译 -->
<h1>{{ t('header.home') }}</h1>
<!-- 带变量 -->
<p>{{ t('user.welcome', { name: 'John' }) }}</p>
<!-- 复数 -->
<p>{{ t('items.count', { count: 1 }, 1) }}</p>
<p>{{ t('items.count', { count: 5 }, 5) }}</p>
<!-- 日期格式化 -->
<p>{{ d(new Date(), 'short') }}</p>
<p>{{ d(new Date(), 'long') }}</p>
<!-- 数字格式化 -->
<p>{{ n(1234.56, 'currency') }}</p>
<p>{{ n(1234.56, 'decimal') }}</p>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { watch } from 'vue'
const { t, d, n, locale } = useI18n()
// 监听语言变化
watch(locale, (newLocale) => {
localStorage.setItem('locale', newLocale)
document.documentElement.lang = newLocale
})
</script>3. 组件内翻译
vue
<template>
<div>
<p>{{ t('message') }}</p>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
// 组件专属翻译
const { t } = useI18n({
messages: {
'en-US': {
message: 'Hello from component'
},
'zh-CN': {
message: '来自组件的消息'
}
}
})
</script>四、日期时间格式化
1. 使用 Intl.DateTimeFormat
javascript
// 日期格式化
function formatDate(date, locale = 'zh-CN') {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
formatDate(new Date(), 'zh-CN') // "2024年1月15日"
formatDate(new Date(), 'en-US') // "January 15, 2024"
// 相对时间
function formatRelativeTime(date, locale = 'zh-CN') {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
const diff = date - new Date()
const days = Math.round(diff / (1000 * 60 * 60 * 24))
if (Math.abs(days) < 1) {
const hours = Math.round(diff / (1000 * 60 * 60))
return rtf.format(hours, 'hour')
}
if (Math.abs(days) < 30) {
return rtf.format(days, 'day')
}
const months = Math.round(days / 30)
return rtf.format(months, 'month')
}
formatRelativeTime(new Date(Date.now() - 3600000), 'zh-CN') // "1小时前"
formatRelativeTime(new Date(Date.now() + 86400000), 'en-US') // "tomorrow"2. 使用 dayjs
javascript
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import localizedFormat from 'dayjs/plugin/localizedFormat'
dayjs.extend(relativeTime)
dayjs.extend(localizedFormat)
// 设置语言
dayjs.locale('zh-cn')
// 格式化
dayjs().format('LL') // "2024年1月15日"
dayjs().format('LLLL') // "2024年1月15日星期一 14:30"
// 相对时间
dayjs().fromNow() // "几秒前"
dayjs().from(dayjs('2024-01-01')) // "14天后"五、数字与货币格式化
1. 使用 Intl.NumberFormat
javascript
// 数字格式化
function formatNumber(num, locale = 'zh-CN') {
return new Intl.NumberFormat(locale).format(num)
}
formatNumber(1234567.89, 'zh-CN') // "1,234,567.89"
formatNumber(1234567.89, 'de-DE') // "1.234.567,89"
// 货币格式化
function formatCurrency(amount, currency, locale) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount)
}
formatCurrency(99.99, 'USD', 'en-US') // "$99.99"
formatCurrency(99.99, 'CNY', 'zh-CN') // "¥99.99"
formatCurrency(99.99, 'EUR', 'de-DE') // "99,99 €"
// 百分比
function formatPercent(num, locale = 'zh-CN') {
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 1
}).format(num)
}
formatPercent(0.1234, 'zh-CN') // "12.3%"
// 紧凑格式
function formatCompact(num, locale = 'zh-CN') {
return new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short'
}).format(num)
}
formatCompact(1234567, 'zh-CN') // "123万"
formatCompact(1234567, 'en-US') // "1.2M"六、RTL 支持
1. CSS 方向支持
css
/* 基础 RTL 支持 */
html[dir="rtl"] {
direction: rtl;
}
/* 使用逻辑属性 (推荐) */
.container {
/* 旧写法 */
/* margin-left: 20px; */
/* padding-right: 10px; */
/* 新写法 - 自动适应 RTL */
margin-inline-start: 20px;
padding-inline-end: 10px;
}
/* 逻辑属性对照表 */
/*
left → inline-start
right → inline-end
top → block-start
bottom → block-end
margin-left → margin-inline-start
margin-right → margin-inline-end
padding-left → padding-inline-start
padding-right → padding-inline-end
border-left → border-inline-start
border-right → border-inline-end
text-align: left → text-align: start
text-align: right → text-align: end
*/
/* Flex 布局自动适应 */
.flex-container {
display: flex;
/* flex-direction: row 自动适应 RTL */
}
/* 需要翻转的图标 */
html[dir="rtl"] .icon-arrow {
transform: scaleX(-1);
}2. JavaScript 检测
javascript
// 检测语言方向
function getDirection(locale) {
const rtlLocales = ['ar', 'he', 'fa', 'ur']
const lang = locale.split('-')[0]
return rtlLocales.includes(lang) ? 'rtl' : 'ltr'
}
// 设置文档方向
function setDirection(locale) {
const dir = getDirection(locale)
document.documentElement.dir = dir
document.documentElement.lang = locale
}
// 语言切换时更新
i18n.on('languageChanged', (lng) => {
setDirection(lng)
})七、最佳实践
1. 翻译 Key 命名规范
json
// 推荐:按页面/功能模块组织
{
"common": {
"actions": {
"save": "保存",
"cancel": "取消"
},
"status": {
"loading": "加载中",
"error": "出错了"
}
},
"user": {
"profile": {
"title": "用户资料",
"name": "姓名",
"email": "邮箱"
}
},
"product": {
"list": {
"title": "商品列表",
"empty": "暂无商品"
}
}
}
// 避免:扁平化难以维护
{
"save": "保存",
"cancel": "取消",
"userProfileTitle": "用户资料"
}2. 提取翻译 Key
javascript
// 使用 i18next-parser 自动提取
// i18next-parser.config.js
module.exports = {
locales: ['en-US', 'zh-CN'],
output: 'src/i18n/locales/$LOCALE/$NAMESPACE.json',
input: ['src/**/*.{js,jsx,ts,tsx}'],
defaultNamespace: 'common',
keySeparator: '.',
namespaceSeparator: ':'
}
// 执行
// npx i18next-parser3. 翻译质量保证
javascript
// 检查缺失的翻译
i18n.init({
saveMissing: true,
missingKeyHandler: (lng, ns, key) => {
console.warn(`Missing translation: ${lng}/${ns}/${key}`)
// 发送到翻译管理平台
}
})
// 翻译覆盖率检查脚本
const fs = require('fs')
function checkCoverage(baseLang, targetLang) {
const base = JSON.parse(fs.readFileSync(`locales/${baseLang}.json`))
const target = JSON.parse(fs.readFileSync(`locales/${targetLang}.json`))
const missing = []
function check(baseObj, targetObj, path = '') {
for (const key in baseObj) {
const currentPath = path ? `${path}.${key}` : key
if (typeof baseObj[key] === 'object') {
check(baseObj[key], targetObj[key] || {}, currentPath)
} else if (!targetObj[key]) {
missing.push(currentPath)
}
}
}
check(base, target)
return missing
}4. 性能优化
javascript
// 1. 按需加载语言包
const loadLocale = async (locale) => {
const messages = await import(`./locales/${locale}.json`)
i18n.addResourceBundle(locale, 'translation', messages.default)
}
// 2. 缓存翻译结果
import { useMemo } from 'react'
function MyComponent({ items }) {
const { t } = useTranslation()
// 避免每次渲染都调用 t()
const labels = useMemo(() => ({
title: t('title'),
description: t('description')
}), [t])
return (
<div>
<h1>{labels.title}</h1>
<p>{labels.description}</p>
</div>
)
}
// 3. 预编译翻译
// 使用 i18next-parser 的 preload 选项八、翻译管理平台
1. 常用平台
| 平台 | 特点 |
|---|---|
| Crowdin | 功能全面,支持多种格式 |
| Lokalise | 开发者友好,API 完善 |
| Phrase | 企业级,工作流完善 |
| POEditor | 简单易用,性价比高 |
2. 集成示例
javascript
// 从翻译平台拉取翻译
// scripts/pull-translations.js
const fetch = require('node-fetch')
const fs = require('fs')
async function pullTranslations() {
const locales = ['en-US', 'zh-CN']
for (const locale of locales) {
const response = await fetch(
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/translations/${locale}`,
{
headers: {
Authorization: `Bearer ${API_TOKEN}`
}
}
)
const data = await response.json()
fs.writeFileSync(
`src/i18n/locales/${locale}.json`,
JSON.stringify(data, null, 2)
)
}
}
pullTranslations()九、高频面试题
1. 前端国际化的实现方案?
1. 翻译文本
- 使用 i18n 库 (react-i18next, vue-i18n)
- JSON/YAML 语言文件
- Key-Value 映射
2. 日期时间格式
- Intl.DateTimeFormat
- dayjs/moment
3. 数字货币格式
- Intl.NumberFormat
4. RTL 支持
- CSS 逻辑属性
- HTML dir 属性
5. 图片资源
- 按语言加载不同图片2. 如何处理复数形式?
javascript
// 不同语言复数规则不同
// 英语: 1 = singular, 其他 = plural
// 中文: 无复数变化
// 俄语: 1个、2-4个、5+个 三种形式
// 阿拉伯语: 6种形式
// i18next 复数处理
{
"items_one": "{{count}} item",
"items_other": "{{count}} items"
}
t('items', { count: 1 }) // "1 item"
t('items', { count: 5 }) // "5 items"
// 自定义复数规则
i18n.services.pluralResolver.addRule('zh', {
numbers: [1],
plurals: (n) => 0 // 中文只有一种形式
})3. 如何实现语言包按需加载?
javascript
// 1. 动态 import
const loadLocale = async (locale) => {
const messages = await import(`./locales/${locale}.json`)
i18n.addResourceBundle(locale, 'translation', messages.default)
}
// 2. 使用 i18next-http-backend
import Backend from 'i18next-http-backend'
i18n.use(Backend).init({
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json'
}
})
// 3. 配合 React Suspense
<Suspense fallback={<Loading />}>
<MyComponent />
</Suspense>4. 如何处理 SEO 多语言?
html
<!-- 1. hreflang 标签 -->
<link rel="alternate" hreflang="en" href="https://example.com/en/" />
<link rel="alternate" hreflang="zh" href="https://example.com/zh/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
<!-- 2. lang 属性 -->
<html lang="zh-CN">
<!-- 3. URL 结构 -->
<!-- 子域名: en.example.com, zh.example.com -->
<!-- 子目录: example.com/en/, example.com/zh/ -->
<!-- 参数: example.com?lang=en -->5. 如何测试国际化?
javascript
// 1. 伪本地化测试
// 将文本加上特殊字符,检查布局是否破坏
// "Hello" → "[Ħëľľő]"
// 2. 长度测试
// 德语比英语长 30%
// 测试最长翻译是否显示正常
// 3. RTL 测试
// 切换到阿拉伯语测试布局
// 4. 自动化测试
import { render, screen } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
test('displays translated text', () => {
render(
<I18nextProvider i18n={i18n}>
<MyComponent />
</I18nextProvider>
)
expect(screen.getByText('欢迎')).toBeInTheDocument()
})