Skip to content

前端国际化 (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-languagedetector
javascript
// 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 i18n

2. 语言文件

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@9
javascript
// 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-parser

3. 翻译质量保证

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()
})