前端国际化(i18n)完整方案:从基础配置到生产最佳实践

HTMLPAGE 团队
16 分钟阅读

全面讲解前端应用的国际化实现方案,涵盖 React/Vue 框架集成、翻译文件管理、复数处理、日期时间格式化、RTL 支持等核心功能的设计与实践。

#国际化 #i18n #React #Vue #多语言

前端国际化(i18n)完整方案:从基础配置到生产最佳实践

随着业务全球化,国际化(i18n)成为现代前端应用的标配。一个完善的国际化方案不仅要处理文本翻译,还需要考虑日期时间、数字货币、复数规则、RTL 布局等多方面因素。本文将详细讲解如何构建企业级的国际化解决方案。

国际化的核心概念

术语解释

  • i18n (Internationalization):国际化,指设计和开发能够适应不同语言和地区的应用
  • L10n (Localization):本地化,指将应用适配到特定语言和地区
  • Locale:地区设置,通常格式为 语言-地区,如 zh-CNen-US

需要国际化的内容

类型示例处理方式
文本内容按钮文案、提示信息翻译文件
日期时间2024年1月1日 / Jan 1, 2024Intl.DateTimeFormat
数字格式1,234.56 / 1.234,56Intl.NumberFormat
货币¥100 / $100Intl.NumberFormat
复数规则1 item / 2 itemsICU 复数语法
排序规则字母/拼音排序Intl.Collator
文字方向LTR/RTLCSS 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`);
});

总结

前端国际化需要考虑:

  1. 翻译管理:结构化的翻译文件,自动提取工具
  2. 复数处理:ICU 格式支持不同语言的复数规则
  3. 格式化:日期、数字、货币的本地化格式
  4. RTL 支持:CSS 逻辑属性适配从右到左语言
  5. 性能优化:按需加载翻译文件
  6. 工作流:翻译状态追踪,CI/CD 集成

完善的国际化方案能让应用快速拓展到全球市场。

延伸阅读