React 国际化 (i18n) 最佳实践

中等 🟡React 生态
11 个标签
预计阅读时间:108 分钟
React国际化i18n本地化i18nextreact-intlnext-intlICURTLIntlNext.js

React 国际化 (i18n) 最佳实践

国际化(Internationalization,简称 i18n)是构建全球应用的重要环节。一个优秀的国际化方案不仅要支持多语言翻译,还需要处理日期时间格式、数字格式、货币符号、复数规则、文字方向等复杂问题。React 提供了多种国际化方案,选择合适的方案对项目的可维护性和用户体验至关重要。

国际化库对比

i18next - 功能最全面的国际化方案:

功能丰富,生态完整,支持 React、Vue、Angular 等多种框架
支持复数、插值、嵌套翻译、命名空间等高级功能
提供浏览器语言检测、后端加载、缓存等插件
支持服务端渲染(SSR)和静态站点生成(SSG)
社区活跃,文档完善,是 React 国际化的首选方案
typescriptCode
// i18next 配置示例
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend) // 从服务器加载翻译文件
  .use(LanguageDetector) // 检测用户语言
  .use(initReactI18next) // 绑定 react-i18next
  .init({
    fallbackLng: 'en', // 默认语言
    supportedLngs: ['en', 'zh', 'ja', 'ko'],
    
    ns: ['common', 'home', 'about'], // 命名空间
    defaultNS: 'common',
    
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json', // 翻译文件路径
    },
    
    detection: {
      order: ['querystring', 'cookie', 'localStorage', 'navigator'],
      caches: ['cookie', 'localStorage'],
    },
    
    interpolation: {
      escapeValue: false, // React 已经处理 XSS
    },
    
    react: {
      useSuspense: true, // 使用 Suspense 加载翻译
    },
  });

export default i18n;

react-intl - Airbnb 出品的国际化方案:

Airbnb 开发的 React 国际化解决方案,专注于格式化
基于 ICU 消息格式,支持复杂的复数和性别规则
提供 FormattedMessage、FormattedDate、FormattedNumber 等组件
支持 React 和 React Native,API 设计一致
适合需要复杂格式化的应用,如电商、金融类应用
typescriptCode
// react-intl 配置示例
import { IntlProvider, FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';

const messages = {
  en: {
    greeting: 'Hello, {name}!',
    items: '{count, plural, one {# item} other {# items}}',
    income: '{gender, select, male {He earns} female {She earns} other {They earn}} {amount}',
  },
  zh: {
    greeting: '你好,{name}!',
    items: '{count} 个项目',
    income: '{gender, select, male {他赚} female {她赚} other {他们赚}} {amount}',
  },
};

function App() {
  const [locale, setLocale] = useState('en');
  
  return (
    <IntlProvider locale={locale} messages={messages[locale]}>
      <div>
        <FormattedMessage id="greeting" values={{ name: 'Alice' }} />
        <FormattedNumber value={1234.56} style="currency" currency="USD" />
        <FormattedDate value={new Date()} year="numeric" month="long" day="numeric" />
      </div>
    </IntlProvider>
  );
}

Format.js - 底层格式化工具集:

一套国际化工具集,react-intl 的底层依赖
支持格式化日期、数字、货币、相对时间等
提供 ICU 消息格式的完整实现
可以独立使用,也可以与其他国际化库配合

实现策略

消息管理最佳实践:

集中管理翻译文件,使用 JSON 或 YAML 格式存储
按功能模块划分命名空间,避免单个文件过大
支持动态加载翻译文件,减少初始加载体积
使用翻译管理工具(如 Crowdin、Lokalise)协作翻译
typescriptCode
// 翻译文件结构示例
// /locales/en/common.json
{
  "welcome": "Welcome to our app",
  "navigation": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "errors": {
    "required": "This field is required",
    "invalidEmail": "Please enter a valid email address"
  }
}

// /locales/zh/common.json
{
  "welcome": "欢迎使用我们的应用",
  "navigation": {
    "home": "首页",
    "about": "关于我们",
    "contact": "联系我们"
  },
  "errors": {
    "required": "此字段为必填项",
    "invalidEmail": "请输入有效的电子邮件地址"
  }
}

// 组件中使用翻译
import { useTranslation } from 'react-i18next';

function Navigation() {
  const { t } = useTranslation('common');
  
  return (
    <nav>
      <Link to="/">{t('navigation.home')}</Link>
      <Link to="/about">{t('navigation.about')}</Link>
      <Link to="/contact">{t('navigation.contact')}</Link>
    </nav>
  );
}

语言检测策略:

从 URL 参数检测(如 ?lang=zh),适合 SEO 和分享链接
从浏览器设置检测(navigator.language),提供默认语言
从用户偏好检测(localStorage/cookie),记住用户选择
优先级:URL 参数 > 用户偏好 > 浏览器设置 > 默认语言
typescriptCode
// 语言切换组件
import { useTranslation } from 'react-i18next';

function LanguageSwitcher() {
  const { i18n } = useTranslation();
  
  const changeLanguage = (lng: string) => {
    i18n.changeLanguage(lng);
    // 保存用户偏好
    localStorage.setItem('preferredLanguage', lng);
    // 更新 URL 参数
    const url = new URL(window.location.href);
    url.searchParams.set('lang', lng);
    window.history.replaceState({}, '', url.toString());
  };
  
  return (
    <div className="language-switcher">
      <button 
        onClick={() => changeLanguage('en')}
        className={i18n.language === 'en' ? 'active' : ''}
      >
        English
      </button>
      <button 
        onClick={() => changeLanguage('zh')}
        className={i18n.language === 'zh' ? 'active' : ''}
      >
        中文
      </button>
    </div>
  );
}

文本提取与翻译工作流:

使用 i18next-scanner 或 babel-plugin-react-intl 自动提取文本
支持批量翻译,导出为 XLIFF 或 CSV 格式
与翻译服务集成,自动化翻译流程
使用 CI/CD 检查翻译完整性
javascriptCode
// i18next-scanner 配置
module.exports = {
  input: ['src/**/*.{js,jsx,ts,tsx}'],
  output: 'public/locales',
  options: {
    debug: true,
    sort: true,
    func: {
      list: ['t', 'i18n.t'],
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
    },
    trans: {
      component: 'Trans',
      i18nKey: 'i18nKey',
      defaultsKey: 'defaults',
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
    },
    lngs: ['en', 'zh', 'ja'],
    ns: ['common', 'home'],
    defaultLng: 'en',
    defaultNs: 'common',
  },
};

性能优化

懒加载翻译文件:

按需加载翻译文件,减少初始加载体积
使用命名空间分隔翻译,按页面或功能加载
配合 React Suspense 提供加载状态
typescriptCode
// 懒加载翻译配置
i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
    // 只加载需要的命名空间
    partialBundledLanguages: true,
    // 预加载常用语言
    preload: ['en'],
  });

// 动态加载命名空间
function AdminPanel() {
  const { t } = useTranslation('admin', { useSuspense: true });
  return <div>{t('title')}</div>;
}

// 使用 Suspense 处理加载状态
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <AdminPanel />
    </Suspense>
  );
}

缓存翻译文件:

使用 Service Worker 缓存翻译文件
利用浏览器 HTTP 缓存
使用 localStorage 缓存已加载的翻译
typescriptCode
// 使用 localStorage 缓存
const localStorageBackend = {
  type: 'localStorage',
  prefix: 'i18next_',
  expirationTime: 7 * 24 * 60 * 60 * 1000, // 7天
};

i18n.use(initReactI18next).init({
  backend: {
    backends: [
      localStorageBackend, // 优先从缓存读取
      HttpBackend, // 缓存未命中时从服务器加载
    ],
  },
});

优化渲染性能:

避免不必要的重新渲染,使用 memo 包裹组件
使用 useMemo 缓存翻译结果
避免在渲染中动态生成翻译 key
typescriptCode
// 优化前:每次渲染都创建新对象
function UserCard({ user }) {
  const { t } = useTranslation();
  return (
    <div>
      <h2>{t('user.greeting', { name: user.name })}</h2>
      <p>{t('user.role', { role: user.role })}</p>
    </div>
  );
}

// 优化后:使用 memo 和 useMemo
const UserCard = memo(function UserCard({ user }) {
  const { t } = useTranslation();
  const values = useMemo(() => ({ name: user.name }), [user.name]);
  return (
    <div>
      <h2>{t('user.greeting', values)}</h2>
      <p>{t('user.role', { role: user.role })}</p>
    </div>
  );
});

最佳实践

组件化翻译:

创建国际化组件封装翻译逻辑
提高代码复用性,统一翻译风格
便于后期维护和修改
typescriptCode
// 封装翻译组件
interface TranslatedTextProps {
  id: string;
  values?: Record<string, string | number>;
  defaultValue?: string;
}

const TranslatedText: React.FC<TranslatedTextProps> = ({ 
  id, 
  values, 
  defaultValue 
}) => {
  const { t } = useTranslation();
  return <>{t(id, values, { defaultValue })}</>;
};

// 使用示例
<TranslatedText id="welcome.message" values={{ name: 'Alice' }} />

占位符和插值:

使用占位符处理动态内容,避免字符串拼接
支持复数形式,不同语言复数规则不同
支持性别变化,某些语言需要根据性别调整
typescriptCode
// 插值示例
// en.json
{
  "greeting": "Hello, {{name}}!",
  "items": "{{count}} item",
  "items_plural": "{{count}} items"
}

// zh.json
{
  "greeting": "你好,{{name}}!",
  "items": "{{count}} 个项目"
}

// 使用
t('greeting', { name: 'Alice' }) // "Hello, Alice!" / "你好,Alice!"
t('items', { count: 1 }) // "1 item" / "1 个项目"
t('items', { count: 5 }) // "5 items" / "5 个项目"

日期和时间本地化:

使用本地化的日期和时间格式
考虑时区问题,显示用户本地时间
使用 Intl.DateTimeFormat 或库处理
typescriptCode
// 使用 Intl API 格式化日期
const formatDate = (date: Date, locale: string) => {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    weekday: 'long',
  }).format(date);
};

// en: "Saturday, March 15, 2026"
// zh: "2026年3月15日星期六"

// 使用 react-intl
import { FormattedDate, FormattedRelativeTime } from 'react-intl';

<FormattedDate 
  value={new Date()} 
  year="numeric" 
  month="long" 
  day="numeric" 
/>

<FormattedRelativeTime 
  value={-5} 
  unit="minute" 
  numeric="auto" 
/>
// "5 minutes ago" / "5分钟前"

数字和货币本地化:

使用本地化的数字格式
正确处理货币符号和位置
使用 Intl.NumberFormat 或库处理
typescriptCode
// 使用 Intl API 格式化数字
const formatNumber = (number: number, locale: string) => {
  return new Intl.NumberFormat(locale).format(number);
};

// en: "1,234,567.89"
// zh: "1,234,567.89"
// de: "1.234.567,89"

// 格式化货币
const formatCurrency = (amount: number, locale: string, currency: string) => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(amount);
};

// en-US, USD: "$1,234.56"
// zh-CN, CNY: "¥1,234.56"
// ja-JP, JPY: "¥1,235"

RTL(从右到左)布局支持:

测试 RTL 布局,如阿拉伯语、希伯来语
使用 CSS 逻辑属性(start/end 代替 left/right)
提供布局方向切换功能
typescriptCode
// RTL 支持
function App() {
  const { i18n } = useTranslation();
  const isRTL = ['ar', 'he', 'fa'].includes(i18n.language);
  
  return (
    <div dir={isRTL ? 'rtl' : 'ltr'} className={isRTL ? 'rtl' : 'ltr'}>
      {/* 内容 */}
    </div>
  );
}

// CSS 逻辑属性
.card {
  padding-inline-start: 16px; /* LTR: padding-left, RTL: padding-right */
  margin-inline-end: 8px;
  border-start-start-radius: 8px; /* LTR: top-left, RTL: top-right */
}

测试国际化:

测试不同语言的渲染效果
测试文本长度变化对布局的影响
测试 RTL 布局
测试日期、数字、货币格式化
typescriptCode
// 国际化测试示例
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';

describe('Navigation', () => {
  it('renders in English', () => {
    i18n.changeLanguage('en');
    render(
      <I18nextProvider i18n={i18n}>
        <Navigation />
      </I18nextProvider>
    );
    expect(screen.getByText('Home')).toBeInTheDocument();
  });

  it('renders in Chinese', () => {
    i18n.changeLanguage('zh');
    render(
      <I18nextProvider i18n={i18n}>
        <Navigation />
      </I18nextProvider>
    );
    expect(screen.getByText('首页')).toBeInTheDocument();
  });
});

i18next 完整进阶

前面只覆盖了 i18next 的基础配置。真实项目里,i18next 的威力体现在命名空间、上下文(context)、嵌套翻译、复数进阶、ICU 插件、Trans 组件、以及插值格式化这些高级能力上。下面逐一深入。

命名空间(Namespace):拆分与按需加载

概念:命名空间是把翻译按逻辑模块(而非语言)切分成多个独立文件的机制。一个语言目录下可以有 common.json、home.json、checkout.json 等多个文件,每个文件就是一个命名空间。

为什么重要:单个 JSON 翻译文件在中大型项目会膨胀到几千个 key、几百 KB。全部一次性加载会拖慢首屏。命名空间让你把翻译按路由或功能拆开,配合按需加载,首屏只下发真正用到的那一小部分。

某跨境电商实测数据:把单文件 380KB 的 zh.json 拆成 12 个命名空间后,首屏只加载 common + home 两个命名空间共 42KB,翻译文件对首屏体积的贡献从 380KB 降到 42KB,降幅 89%。

typescriptCode
// 目录结构
// public/locales/
//   ├── en/
//   │   ├── common.json      通用文案:按钮、导航、错误
//   │   ├── home.json        首页专属
//   │   ├── checkout.json    结算流程专属
//   │   └── account.json     账户中心专属
//   └── zh/
//       ├── common.json
//       ├── home.json
//       ├── checkout.json
//       └── account.json

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    ns: ['common'],           // 初始只加载 common
    defaultNS: 'common',
    fallbackNS: 'common',     // 某命名空间缺 key 时回退到 common
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
  });

在组件里按需请求命名空间,i18next 会自动异步加载对应文件:

typescriptCode
import { useTranslation } from 'react-i18next';

// 结算页:声明它需要 checkout 命名空间
function CheckoutPage() {
  // 第一个参数可以传数组,同时加载多个命名空间
  const { t } = useTranslation(['checkout', 'common']);

  return (
    <div>
      {/* 默认从第一个命名空间 checkout 取 */}
      <h1>{t('checkout:title')}</h1>
      {/* 显式指定 common 命名空间 */}
      <button>{t('common:actions.confirm')}</button>
      {/* 不写前缀时用默认命名空间 checkout */}
      <p>{t('shipping.estimate')}</p>
    </div>
  );
}

key 命名约定:推荐用 "命名空间:分组.具体key" 的三段式。命名空间对应功能模块,分组对应页面区块或语义类别(如 actions、errors、labels),具体 key 用小驼峰。避免把整句英文当 key(如 t('Please enter your email')),因为文案一改 key 就失效,且不利于多语言维护。

context:同一 key 的场景化变体

概念:context 让同一个逻辑 key 根据上下文参数产生不同翻译。最典型的是性别区分——很多语言里"朋友"这个词男女形式不同。

jsonCode
// de/common.json(德语,朋友分阳性/阴性)
{
  "friend": "Freund",
  "friend_male": "Freund",
  "friend_female": "Freundin"
}
typescriptCode
t('friend');                          // "Freund"(无 context 时用基础形式)
t('friend', { context: 'male' });     // "Freund"
t('friend', { context: 'female' });   // "Freundin"

context 还能和复数组合,i18next 会拼出 "key_context_复数类别" 的完整 key:

jsonCode
{
  "message_male_one": "他给你发了 {{count}} 条消息",
  "message_male_other": "他给你发了 {{count}} 条消息",
  "message_female_one": "她给你发了 {{count}} 条消息",
  "message_female_other": "她给你发了 {{count}} 条消息"
}
typescriptCode
t('message', { context: 'female', count: 3 });
// "她给你发了 3 条消息"

嵌套(nesting)与引用

概念:嵌套允许一条翻译引用另一条翻译,用 `$t(key)` 语法。适合复用品牌名、产品名等重复片段。

jsonCode
{
  "appName": "极光云",
  "welcome": "欢迎使用 $t(appName)",
  "footer": "$t(appName) 版权所有 © 2026",
  "nested": {
    "deep": "这是 $t(appName) 的深层引用"
  }
}
typescriptCode
t('welcome');  // "欢迎使用 极光云"
t('footer');   // "极光云 版权所有 © 2026"

嵌套还能传参给被引用的 key:

jsonCode
{
  "count_one": "{{count}} 项",
  "count_other": "{{count}} 项",
  "summary": "共 $t(count, {\"count\": {{total}} })"
}

复数进阶:i18next 的复数机制

i18next v21 起使用 Intl.PluralRules 的 CLDR 复数类别(zero/one/two/few/many/other)。key 的后缀直接对应类别名,而不再是老版本的 _plural:

jsonCode
// en:英语只有 one / other
{
  "cart_one": "购物车里有 {{count}} 件商品",
  "cart_other": "购物车里有 {{count}} 件商品"
}

// ar:阿拉伯语有全部 6 种类别
{
  "cart_zero": "السلة فارغة",
  "cart_one": "سلعة واحدة في السلة",
  "cart_two": "سلعتان في السلة",
  "cart_few": "{{count}} سلع في السلة",
  "cart_many": "{{count}} سلعة في السلة",
  "cart_other": "{{count}} سلعة في السلة"
}
typescriptCode
t('cart', { count: 0 });   // ar → "السلة فارغة"
t('cart', { count: 1 });   // ar → "سلعة واحدة في السلة"
t('cart', { count: 2 });   // ar → "سلعتان في السلة"
t('cart', { count: 5 });   // ar → few 分支
t('cart', { count: 15 });  // ar → many 分支

常见坑:中文、日文、韩文没有复数变化,只有 other 一个类别。给这些语言写 _one/_other 是无害的(都指向 other),但不要以为写了 _one 中文就会用——它永远走 other。

ICU 插件:i18next-icu

概念:i18next 原生插值语法({{var}}、_one/_other 后缀)比较简单,处理复杂的嵌套复数、select、内联格式化时不如 ICU MessageFormat 强大。i18next-icu 插件让 i18next 直接支持 ICU 语法,与 react-intl 的消息格式打通。

typescriptCode
import i18n from 'i18next';
import ICU from 'i18next-icu';
import { initReactI18next } from 'react-i18next';

i18n
  .use(ICU)                 // 启用 ICU 解析
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
  });

启用后翻译文件可以直接写 ICU 语法:

jsonCode
{
  "notification": "{count, plural, =0 {没有新通知} one {有 # 条新通知} other {有 # 条新通知}}",
  "invite": "{gender, select, male {他邀请了你} female {她邀请了你} other {对方邀请了你}}",
  "price": "价格:{amount, number, ::currency/CNY}"
}
typescriptCode
t('notification', { count: 0 });   // "没有新通知"
t('notification', { count: 5 });   // "有 5 条新通知"
t('invite', { gender: 'female' }); // "她邀请了你"
t('price', { amount: 99.9 });      // "价格:¥99.90"

Trans 组件:处理富文本与嵌套标签

概念:当翻译内容里包含 HTML 标签或 React 组件(如链接、加粗、图标)时,纯 t() 无法安全地插入组件。react-i18next 的 Trans 组件专门解决这个问题——它把翻译字符串里的占位标签映射回真实 React 元素,避免字符串拼接和 dangerouslySetInnerHTML。

为什么重要:新手常写 `

{t('agree')} {t('terms')}

`,把一句话拆成两半翻译。但不同语言的语序不同(英语链接在句中,某些语言在句尾),拆开翻译会导致语序错乱、译员无法看到完整句子。Trans 让整句作为一条翻译,标签位置由翻译文件决定。

jsonCode
// en
{
  "agreement": "我已阅读并同意 <1>服务条款</1> 和 <3>隐私政策</3>",
  "welcome": "欢迎,<bold>{{name}}</bold>!你有 <link>3 条未读消息</link>"
}
typescriptCode
import { Trans } from 'react-i18next';

function TermsCheckbox() {
  return (
    <label>
      <input type="checkbox" />
      {/* 数字索引对应子元素顺序:<1> 是第 2 个子节点 <a> */}
      <Trans i18nKey="agreement">
        我已阅读并同意 <a href="/terms">服务条款</a> 和 <a href="/privacy">隐私政策</a>
      </Trans>
    </label>
  );
}

// 也可以用具名标签(更可读),通过 components 映射
function Welcome({ name }: { name: string }) {
  return (
    <Trans
      i18nKey="welcome"
      values={{ name }}
      components={{
        bold: <strong className="font-bold" />,
        link: <a href="/messages" className="text-blue-500" />,
      }}
    />
  );
}

Trans 的坑:i18nKey 对应的翻译里,标签索引必须和 JSX 子节点顺序严格一致。若用自闭合组件(如 `
`)也会占一个索引位。推荐优先用具名标签 + components 映射,比数字索引更抗重构。

interpolation 格式化:内联 formatter

概念:插值时可以对变量做格式化,语法是 `{{value, formatName}}`。i18next v21+ 内置基于 Intl 的 number、currency、datetime、relativetime、list 等格式化器。

jsonCode
{
  "stat": "总销售额 {{amount, currency}},共 {{orders, number}} 单",
  "signup": "注册于 {{date, datetime}}",
  "tags": "标签:{{items, list}}"
}
typescriptCode
i18n.init({
  fallbackLng: 'zh',
  interpolation: {
    escapeValue: false,
  },
});

t('stat', {
  amount: 128900,
  orders: 342,
  formatParams: {
    amount: { currency: 'CNY' },  // 传给 Intl.NumberFormat 的选项
  },
});
// "总销售额 ¥128,900.00,共 342 单"

t('signup', {
  date: new Date('2026-08-03'),
  formatParams: {
    date: { year: 'numeric', month: 'long', day: 'numeric' },
  },
});
// "注册于 2026年8月3日"

也可以注册自定义 formatter,处理项目特有的格式(如手机号打码):

typescriptCode
i18n.services.formatter.add('maskPhone', (value) => {
  return String(value).replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
});

// 翻译文件:{ "profile": "手机号:{{phone, maskPhone}}" }
t('profile', { phone: '13812345678' });
// "手机号:138****5678"

react-intl 深入

react-intl 属于 FormatJS 生态,核心理念是"消息即数据"——所有文案通过 defineMessages 声明成带 id/defaultMessage 的对象,配合编译期提取工具形成完整工作流。它对 ICU MessageFormat 的支持是一等公民,适合格式化需求重的应用。

defineMessages 与消息定义

概念:defineMessages 把散落在组件里的文案集中声明为消息描述符对象。它本身不做翻译,但为提取工具(@formatjs/cli)提供了可静态分析的锚点。

typescriptCode
import { defineMessages } from 'react-intl';

// 推荐把消息定义放在组件文件顶部或单独的 messages.ts
export const messages = defineMessages({
  cartTitle: {
    id: 'cart.title',
    defaultMessage: '购物车',
    description: '购物车页面标题,给译员的说明',  // description 只给译员看
  },
  itemCount: {
    id: 'cart.itemCount',
    defaultMessage: '{count, plural, one {# 件商品} other {# 件商品}}',
  },
  checkout: {
    id: 'cart.checkout',
    defaultMessage: '去结算',
  },
});

useIntl 与 intl.formatMessage

概念:useIntl 是访问 intl 对象的 Hook,intl.formatMessage 是核心翻译函数。相比组件式的 FormattedMessage,命令式 API 更适合在属性、aria-label、title 等非 JSX 子节点位置使用。

typescriptCode
import { useIntl } from 'react-intl';
import { messages } from './messages';

function CartHeader({ count }: { count: number }) {
  const intl = useIntl();

  return (
    <header>
      {/* 命令式:适合放进 title/placeholder/aria-label */}
      <h1 title={intl.formatMessage(messages.cartTitle)}>
        {intl.formatMessage(messages.cartTitle)}
      </h1>
      {/* 带参数的复数消息 */}
      <span aria-label={intl.formatMessage(messages.itemCount, { count })}>
        {intl.formatMessage(messages.itemCount, { count })}
      </span>
      <button aria-label={intl.formatMessage(messages.checkout)}>
        {intl.formatMessage(messages.checkout)}
      </button>
    </header>
  );
}

组件式写法(等价,适合直接渲染的场景):

typescriptCode
import { FormattedMessage } from 'react-intl';

function CartHeaderJSX({ count }: { count: number }) {
  return (
    <header>
      <h1><FormattedMessage {...messages.cartTitle} /></h1>
      <FormattedMessage {...messages.itemCount} values={{ count }} />
    </header>
  );
}

FormattedList 与 FormattedPlural

FormattedList 基于 Intl.ListFormat,正确处理不同语言的列表连接词(英语的 "A, B, and C"、中文的顿号):

typescriptCode
import { FormattedList, FormattedPlural } from 'react-intl';

function TagList({ tags }: { tags: string[] }) {
  return (
    <FormattedList type="conjunction" value={tags} />
  );
}
// en: "React, Vue, and Angular"
// zh: "React、Vue和Angular"

// type="disjunction" 表示"或"关系
// en: "React, Vue, or Angular"

function Notifications({ count }: { count: number }) {
  return (
    <span>
      你有 <FormattedPlural
        value={count}
        one="1 条通知"
        other={`${count} 条通知`}
      />
    </span>
  );
}

消息提取:@formatjs/cli

概念:@formatjs/cli 扫描源码里的 defineMessages / FormattedMessage,提取出所有 id + defaultMessage,生成待翻译的 JSON。这是 react-intl 工作流的关键——文案在代码里就是源,提取后交给译员,翻译回来再编译。

bashCode
# 提取所有消息到 lang/en.json
npx formatjs extract 'src/**/*.{ts,tsx}' \
  --out-file lang/en.json \
  --id-interpolation-pattern '[sha512:contenthash:base64:6]'

# 提取结果(en.json)
# {
#   "cart.title": { "defaultMessage": "购物车", "description": "购物车页面标题" },
#   "cart.itemCount": { "defaultMessage": "{count, plural, ...}" }
# }

编译预处理:提升运行时性能

概念:ICU 消息字符串在运行时解析成 AST 有开销。@formatjs/cli 的 compile 命令把消息预编译成 AST 格式,运行时直接消费,跳过解析步骤。

bashCode
# 把译员翻译好的 zh.json 编译成运行时格式
npx formatjs compile lang/zh.json --out-file compiled/zh.json

# 配合 babel-plugin-formatjs 在构建时移除 defaultMessage,
# 减小包体积(生产环境只保留 id)
javascriptCode
// babel.config.js
module.exports = {
  plugins: [
    ['formatjs', {
      idInterpolationPattern: '[sha512:contenthash:base64:6]',
      ast: true,  // 编译成 AST,运行时零解析开销
    }],
  ],
};

某金融 SaaS 实测:启用 ast: true 编译后,含 1200 条 ICU 消息的应用,消息格式化的运行时耗时从首次渲染 38ms 降到 11ms,降幅 71%,因为跳过了运行时的 ICU 词法/语法分析。

Next.js 国际化

Next.js App Router 的国际化和纯客户端 SPA 完全不同。翻译需要在服务端组件(RSC)里可用、要处理 SSR/SSG 的语言选择、要做 SEO 层面的 hreflang 和 metadata 本地化。next-intl 是目前 App Router 下最成熟的方案。

App Router 的 [locale] 动态段

概念:App Router 用文件系统路由。国际化的标准做法是把整个 app 目录套进一个 [locale] 动态段,让语言成为 URL 路径的第一段(/en/about、/zh/about),这对 SEO 和分享链接最友好。

typescriptCode
// 目录结构
// app/
//   ├── [locale]/
//   │   ├── layout.tsx        根布局,设置 lang 和 dir
//   │   ├── page.tsx          首页
//   │   ├── about/page.tsx
//   │   └── checkout/page.tsx
//   ├── i18n/
//   │   ├── routing.ts        路由配置
//   │   └── request.ts        每请求的翻译加载
//   └── middleware.ts         语言检测与重定向

// app/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'zh', 'ja', 'ar'],
  defaultLocale: 'en',
  // 默认语言是否也带前缀。'as-needed' 表示默认语言不带前缀(/about),
  // 其他语言带前缀(/zh/about)
  localePrefix: 'as-needed',
});

middleware 语言重定向

概念:middleware 在请求到达页面前运行,负责根据 URL、cookie、Accept-Language 头决定用户语言,并在必要时重定向到带正确 locale 前缀的路径。

typescriptCode
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './app/i18n/routing';

export default createMiddleware(routing);

export const config = {
  // 匹配所有路径,排除 api、静态资源、_next
  matcher: ['/((?!api|_next|_vercel|.*\..*).*)'],
};

// 用户访问 / 且浏览器语言是 zh-CN → 302 重定向到 /zh
// 用户访问 /about 且带 cookie NEXT_LOCALE=ja → 重定向到 /ja/about

next-intl 完整用法

概念:next-intl 提供 getTranslations(服务端)和 useTranslations(客户端)两套 API,让翻译在 RSC 和客户端组件里都能用,且服务端只序列化真正用到的消息,避免把整包翻译塞进客户端 bundle。

typescriptCode
// app/i18n/request.ts —— 每个请求加载对应语言的消息
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }
  return {
    locale,
    // 只加载该语言的消息文件
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
typescriptCode
// app/[locale]/layout.tsx —— 根布局,注入 Provider
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '../i18n/routing';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!routing.locales.includes(locale as any)) notFound();

  // 服务端拿到该请求的全部消息,传给客户端 Provider
  const messages = await getMessages();
  const dir = ['ar', 'he', 'fa'].includes(locale) ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

SSR/SSG/RSC 下的翻译

服务端组件用 getTranslations,它是 async 的,翻译在服务端完成,HTML 直出,无客户端闪烁:

typescriptCode
// app/[locale]/about/page.tsx —— 服务端组件(默认)
import { getTranslations } from 'next-intl/server';

export default async function AboutPage() {
  // 服务端翻译,这段组件的 JS 不会进入客户端 bundle
  const t = await getTranslations('about');
  return (
    <main>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </main>
  );
}

客户端组件用 useTranslations Hook(需要 'use client'):

typescriptCode
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';

function Counter() {
  const t = useTranslations('counter');
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      {t('clicked', { count })}
    </button>
  );
}

静态生成(SSG):为每个 locale 预生成静态页面,用 generateStaticParams:

typescriptCode
import { routing } from '../i18n/routing';

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
// 构建时生成 /en、/zh、/ja、/ar 四套静态 HTML

SEO:hreflang 与 metadata 本地化

概念:多语言站点必须告诉搜索引擎"同一内容有哪些语言版本",用 hreflang 标签。否则 Google 可能把不同语言页面判定为重复内容,或给用户推错语言版本。metadata(title、description、og)也要按语言翻译。

typescriptCode
// app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'about.meta' });

  return {
    title: t('title'),
    description: t('description'),
    alternates: {
      canonical: `https://example.com/${locale}/about`,
      // hreflang:列出所有语言版本
      languages: {
        en: 'https://example.com/en/about',
        zh: 'https://example.com/zh/about',
        ja: 'https://example.com/ja/about',
        ar: 'https://example.com/ar/about',
        'x-default': 'https://example.com/en/about',
      },
    },
    openGraph: {
      title: t('title'),
      description: t('description'),
      locale,
    },
  };
}

某内容站点复盘:补齐 hreflang 后,非英语版本页面在对应语言区域的自然搜索曝光量 3 个月内提升 2.4 倍,因为 Google 不再把 /zh 判为 /en 的重复页而降权。

复数规则深入

复数是国际化最容易踩坑的地方,因为开发者往往以"英语只有单复数两种"的直觉去写,而世界上的语言复数规则千差万别。

CLDR 复数类别

概念:Unicode CLDR 定义了 6 个抽象复数类别:zero、one、two、few、many、other。每种语言把具体数字映射到这些类别的规则不同。other 是必选的兜底类别,其他都是可选的。

| 语言 | 拥有的复数类别 | 示例(n=商品数) |

| --- | --- | --- |

| 中文/日文/韩文 | other | 所有数字都用 other,无变化 |

| 英文 | one, other | 1 用 one,其余用 other |

| 法文 | one, many, other | 0 和 1 用 one |

| 俄文 | one, few, many, other | 复杂:看个位和十位 |

| 波兰文 | one, few, many, other | 规则又和俄文不同 |

| 阿拉伯文 | zero, one, two, few, many, other | 全部 6 种都用 |

| 威尔士文 | zero, one, two, few, many, other | 全部 6 种 |

Intl.PluralRules:判断数字属于哪个类别

概念:Intl.PluralRules 是浏览器内置 API,输入语言和数字,输出 CLDR 类别名。所有主流 i18n 库底层都用它。

typescriptCode
// 英语
const en = new Intl.PluralRules('en');
en.select(0);  // "other"
en.select(1);  // "one"
en.select(2);  // "other"

// 俄语的复杂性
const ru = new Intl.PluralRules('ru');
ru.select(1);   // "one"   (1, 21, 31...)
ru.select(2);   // "few"   (2-4, 22-24...)
ru.select(5);   // "many"  (5-20, 25-30...)
ru.select(11);  // "many"
ru.select(21);  // "one"

// 阿拉伯语
const ar = new Intl.PluralRules('ar');
ar.select(0);   // "zero"
ar.select(1);   // "one"
ar.select(2);   // "two"
ar.select(3);   // "few"
ar.select(11);  // "many"
ar.select(100); // "other"

// 序数词(第 1、第 2)用 type: 'ordinal'
const enOrd = new Intl.PluralRules('en', { type: 'ordinal' });
enOrd.select(1);  // "one"  → 1st
enOrd.select(2);  // "two"  → 2nd
enOrd.select(3);  // "few"  → 3rd
enOrd.select(4);  // "other" → 4th
enOrd.select(21); // "one"  → 21st

俄语/波兰语/阿拉伯语复数实战

俄语商品数的四类别完整写法:

jsonCode
// ru.json(ICU 语法)
{
  "products": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товара}}"
}
typescriptCode
formatMessage(messages.products, { count: 1 });   // "1 товар"
formatMessage(messages.products, { count: 3 });   // "3 товара"
formatMessage(messages.products, { count: 5 });   // "5 товаров"
formatMessage(messages.products, { count: 21 });  // "21 товар"(21 走 one)

关键教训:给俄语只写 one/other 两个分支是错误的——21 个商品会显示成 "21 товаров"(错,应为 "21 товар"),因为你没写 one 分支或漏了 few/many。翻译文件的复数分支必须由目标语言的母语译员按 CLDR 规则填全。

ICU MessageFormat 完整语法

概念:ICU MessageFormat 是一种在单条字符串里表达复数、性别、嵌套、格式化的 DSL,由 Unicode 组织制定。react-intl 原生支持,i18next 通过 i18next-icu 支持。掌握它就掌握了跨库通用的消息写法。

基础插值与选择(select)

codeCode
简单插值:   你好,{name}!
select 性别: {gender, select, male {他} female {她} other {对方}}关注了你

select 用于枚举分支(性别、类型、状态),other 必填:

jsonCode
{
  "activity": "{type, select, like {点赞了} comment {评论了} share {分享了} other {操作了}}你的动态"
}

plural 与 # 占位符

plural 分支里的 `#` 代表当前数字(会自动本地化格式)。`=数字` 可精确匹配特定值,优先于类别:

jsonCode
{
  "likes": "{count, plural, =0 {还没有人点赞} =1 {你的好友点赞了} one {# 次点赞} other {# 次点赞}}"
}
typescriptCode
// count=0 → "还没有人点赞"(精确匹配 =0,不走 other)
// count=1 → "你的好友点赞了"(精确匹配 =1,不走 one)
// count=1000 → "1,000 次点赞"(# 自动加千分位)

selectordinal:序数词

jsonCode
{
  "rank": "你排名第 {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"
}
typescriptCode
// place=1 → "第 1st",place=2 → "第 2nd",place=3 → "第 3rd",place=4 → "第 4th"

嵌套:plural 里套 select

概念:真实文案常需要复数和性别同时变化。ICU 允许分支里嵌套另一个 select/plural,这是纯 i18next 后缀语法做不到的。

jsonCode
{
  "invites": "{gender, select, male {他} female {她} other {他们}}邀请了 {count, plural, one {# 位朋友} other {# 位朋友}}"
}
typescriptCode
formatMessage(messages.invites, { gender: 'female', count: 3 });
// "她邀请了 3 位朋友"

内联格式化:number、date、time

ICU 消息里可直接调用格式化器,语法 `{var, number, style}`:

jsonCode
{
  "order": "订单 {id},金额 {total, number, ::currency/CNY},下单于 {date, date, long}",
  "progress": "完成度 {ratio, number, ::percent}"
}
typescriptCode
formatMessage(messages.order, {
  id: 'A1001',
  total: 1299.5,
  date: new Date('2026-08-03'),
});
// "订单 A1001,金额 ¥1,299.50,下单于 2026年8月3日"

formatMessage(messages.progress, { ratio: 0.856 });
// "完成度 85.6%"

`::` 开头的是 ICU 的 skeleton 语法(number skeleton),比老式的参数写法更简洁强大,是目前推荐写法。

日期时间深入

日期时间是国际化里 bug 最多的领域,核心难点是时区和格式的双重变化。

时区处理的核心原则

原则:服务端和数据库统一用 UTC 存储,展示时才转成用户本地时区。绝不要在业务逻辑里用本地时间做计算。

typescriptCode
// 后端返回 ISO 8601 带时区的字符串(永远带 Z 或偏移量)
const serverTime = '2026-08-03T14:30:00Z';  // UTC

// 展示时指定用户时区,Intl 自动换算
const formatInTimezone = (iso: string, locale: string, timeZone: string) => {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'full',
    timeStyle: 'short',
    timeZone,
  }).format(new Date(iso));
};

formatInTimezone('2026-08-03T14:30:00Z', 'zh-CN', 'Asia/Shanghai');
// "2026年8月3日星期一 22:30"(UTC+8)
formatInTimezone('2026-08-03T14:30:00Z', 'en-US', 'America/New_York');
// "Monday, August 3, 2026 at 10:30 AM"(UTC-4,夏令时)

Intl.DateTimeFormat 全部选项

typescriptCode
const date = new Date('2026-08-03T14:30:45Z');

// 预设风格(推荐,自动适配语言习惯)
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(date);
// "2026年8月3日星期一"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(date);
// "2026年8月3日"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium' }).format(date);
// "2026年8月3日"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short' }).format(date);
// "2026/8/3"

// 细粒度选项组合
new Intl.DateTimeFormat('en-US', {
  weekday: 'long',      // long | short | narrow
  year: 'numeric',      // numeric | 2-digit
  month: 'long',        // numeric | 2-digit | long | short | narrow
  day: 'numeric',
  hour: 'numeric',
  minute: '2-digit',
  second: '2-digit',
  hour12: true,         // 12 小时制 vs 24 小时制
  timeZoneName: 'short',// 显示时区缩写
  timeZone: 'Asia/Tokyo',
}).format(date);

// 只要月和年(如账单周期)
new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(date);
// "2026年8月"

// 非公历:伊斯兰历、佛历、和历
new Intl.DateTimeFormat('zh-CN-u-ca-chinese').format(date);      // 农历
new Intl.DateTimeFormat('ja-JP-u-ca-japanese').format(date);     // 和历(令和)
new Intl.DateTimeFormat('ar-SA-u-ca-islamic').format(date);      // 伊斯兰历

Intl.RelativeTimeFormat:相对时间

概念:相对时间("3 天前"、"in 2 hours")的格式因语言而异,Intl.RelativeTimeFormat 内置处理。numeric: 'auto' 会在合适时用"昨天/明天"等自然词。

typescriptCode
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });
rtf.format(-1, 'day');    // "昨天"(numeric auto)
rtf.format(-2, 'day');    // "前天"
rtf.format(1, 'day');     // "明天"
rtf.format(-3, 'hour');   // "3 小时前"
rtf.format(2, 'week');    // "2 周后"

const rtfEn = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
rtfEn.format(-1, 'day');  // "1 day ago"(always 强制数字)

// 封装成"多久前"工具
function timeAgo(date: Date, locale: string): string {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diffSec = (date.getTime() - Date.now()) / 1000;
  const units: [Intl.RelativeTimeFormatUnit, number][] = [
    ['year', 31536000], ['month', 2592000], ['day', 86400],
    ['hour', 3600], ['minute', 60], ['second', 1],
  ];
  for (const [unit, sec] of units) {
    if (Math.abs(diffSec) >= sec || unit === 'second') {
      return rtf.format(Math.round(diffSec / sec), unit);
    }
  }
  return '';
}

Temporal API 展望

概念:Temporal 是 JS 新一代日期时间 API(已进入 Stage 3,2026 年多数环境需 polyfill),彻底解决了 Date 的时区混乱、可变性等历史问题。它区分 Instant(时间点)、PlainDate(无时区日期)、ZonedDateTime(带时区)等清晰类型。

typescriptCode
// 需 @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill';

// 明确的带时区时间
const zoned = Temporal.ZonedDateTime.from({
  year: 2026, month: 8, day: 3, hour: 22, minute: 30,
  timeZone: 'Asia/Shanghai',
});
zoned.toString();  // "2026-08-03T22:30:00+08:00[Asia/Shanghai]"

// 时区换算无歧义
const inTokyo = zoned.withTimeZone('Asia/Tokyo');
inTokyo.hour;  // 23(东京比上海快 1 小时)

// 日期运算不可变、清晰
const nextWeek = Temporal.PlainDate.from('2026-08-03').add({ days: 7 });
nextWeek.toString();  // "2026-08-10"

// 与 Intl 配合
zoned.toLocaleString('zh-CN', { dateStyle: 'full', timeStyle: 'short' });

Luxon 与 date-fns 本地化

在 Temporal 普及前,Luxon 和 date-fns 是主流选择:

typescriptCode
// Luxon:内置时区和 i18n,API 现代
import { DateTime } from 'luxon';

DateTime.fromISO('2026-08-03T14:30:00Z')
  .setZone('Asia/Shanghai')
  .setLocale('zh')
  .toLocaleString(DateTime.DATETIME_FULL);
// "2026年8月3日 GMT+8 22:30"

DateTime.now().setLocale('fr').toRelative();  // "il y a 2 heures"

// date-fns:函数式、可 tree-shaking,locale 需显式导入
import { format, formatDistance } from 'date-fns';
import { zhCN, enUS } from 'date-fns/locale';

format(new Date('2026-08-03'), 'PPPP', { locale: zhCN });
// "2026年8月3日星期一"
formatDistance(new Date('2026-08-01'), new Date('2026-08-03'), { locale: zhCN });
// "2 天"

选型建议:新项目若目标环境支持,直接上 Temporal + Intl,零额外体积。需要广泛兼容且要时区计算选 Luxon(约 70KB)。只做格式化、极度在意体积选 date-fns(按需引入,单函数几 KB)。

数字、货币、单位、列表格式化

Intl.NumberFormat 的进阶用法

typescriptCode
// 紧凑记数(notation: compact)——社交产品的点赞数
new Intl.NumberFormat('zh-CN', { notation: 'compact' }).format(12800);
// "1.3万"
new Intl.NumberFormat('en-US', { notation: 'compact' }).format(12800);
// "13K"
new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'long' })
  .format(1200000);
// "1.2 million"

// 单位(style: unit)
new Intl.NumberFormat('zh-CN', { style: 'unit', unit: 'kilometer-per-hour' })
  .format(120);
// "120 公里/小时"
new Intl.NumberFormat('en-US', { style: 'unit', unit: 'megabyte', unitDisplay: 'narrow' })
  .format(256);
// "256MB"

// 百分比
new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1 })
  .format(0.8567);
// "85.7%"

// 科学计数、工程计数
new Intl.NumberFormat('en', { notation: 'scientific' }).format(123456);
// "1.235E5"
new Intl.NumberFormat('en', { notation: 'engineering' }).format(123456);
// "123.456E3"

// 精度与符号控制
new Intl.NumberFormat('en-US', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  signDisplay: 'exceptZero',  // 正数也带 +
}).format(3.5);
// "+3.50"

货币格式化的细节

typescriptCode
// 不同货币的小数位由 ISO 4217 决定,Intl 自动处理
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1235);
// "¥1,235"(日元无小数)
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
// "$1,234.50"
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
// "1.234,50 €"(欧元符号在后,德语用点分组、逗号小数)

// 货币符号显示方式
new Intl.NumberFormat('en-US', {
  style: 'currency', currency: 'CNY', currencyDisplay: 'name',
}).format(100);
// "100.00 Chinese yuan"
new Intl.NumberFormat('en-US', {
  style: 'currency', currency: 'CNY', currencyDisplay: 'code',
}).format(100);
// "CNY 100.00"

关键坑:货币金额绝不能用浮点数做运算后直接格式化。0.1 + 0.2 在 JS 里等于 0.30000000000000004。金额应以最小单位(分)的整数存储和计算,展示时才除以 100 格式化,或用 decimal.js 等库。

Intl.ListFormat:列表连接

typescriptCode
const items = ['苹果', '香蕉', '橙子'];

new Intl.ListFormat('zh-CN', { type: 'conjunction' }).format(items);
// "苹果、香蕉和橙子"
new Intl.ListFormat('en-US', { type: 'conjunction' }).format(items);
// "苹果, 香蕉, and 橙子"(连接词 and)
new Intl.ListFormat('en-US', { type: 'disjunction' }).format(['a', 'b', 'c']);
// "a, b, or c"(或 关系)
new Intl.ListFormat('en-US', { type: 'unit', style: 'narrow' }).format(['5h', '30m']);
// "5h 30m"

Intl.Collator:本地化排序

概念:不同语言的字符排序规则不同(德语 ä 的位置、中文按拼音还是笔画)。直接用 Array.sort 的默认字典序会得到错误结果。Intl.Collator 提供符合语言习惯的比较。

typescriptCode
// 默认字典序把中文按 Unicode 码点排,结果混乱
['张三', '李四', '王五'].sort();  // 按码点,非拼音序

// 用 Collator 按拼音排
const collator = new Intl.Collator('zh-CN');
['张三', '李四', '王五'].sort(collator.compare);
// ["李四", "王五", "张三"](按拼音 L < W < Z)

// 数字感知排序(file2 排在 file10 前)
const natural = new Intl.Collator('en', { numeric: true });
['file10', 'file2', 'file1'].sort(natural.compare);
// ["file1", "file2", "file10"]

// 忽略大小写和音标
new Intl.Collator('en', { sensitivity: 'base' }).compare('café', 'CAFE'); // 0(相等)

Intl.Segmenter:分词

概念:中文、日文、泰文没有空格分词。Intl.Segmenter 按语言规则切分字素、词、句子,用于字数统计、截断、搜索高亮。

typescriptCode
// 按词切分中文
const seg = new Intl.Segmenter('zh-CN', { granularity: 'word' });
const segments = [...seg.segment('我爱自然语言处理')];
segments.filter((s) => s.isWordLike).map((s) => s.segment);
// ["我", "爱", "自然", "语言", "处理"]

// 按字素切分(正确处理 emoji、组合字符)
const graphemeSeg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...graphemeSeg.segment('👨‍👩‍👧')].length;  // 1(一个家庭 emoji 算一个字素)
'👨‍👩‍👧'.length;  // 8(用 .length 会数错)

RTL(从右到左)深入

阿拉伯语、希伯来语、波斯语、乌尔都语是 RTL 语言。全球 RTL 用户超过 5 亿。RTL 支持远不止 dir="rtl" 一行。

双向文本(bidi)

概念:RTL 语境里嵌入 LTR 内容(如英文单词、数字、URL)会产生双向文本,浏览器按 Unicode Bidirectional Algorithm 处理。多数情况自动正确,但用户生成内容拼接时可能错乱,需要用隔离字符或 CSS。

typescriptCode
// 用户名可能是任意语言,嵌入 RTL 句子时用 dir="auto" 隔离
function Comment({ username, text }: { username: string; text: string }) {
  return (
    <p>
      {/* dir="auto" 让浏览器自动检测每段方向,避免标点错位 */}
      <bdi>{username}</bdi>:<span dir="auto">{text}</span>
    </p>
  );
}
// <bdi> 元素天然隔离双向文本,比手动插入 U+2068/U+2069 更简洁

CSS 逻辑属性完整清单

概念:物理属性(left/right/top/bottom)在 RTL 下不会自动翻转。逻辑属性用 inline(行内,随文字方向)和 block(块向)替代,浏览器按 dir 自动映射,一套 CSS 同时适配 LTR 和 RTL。

| 物理属性 | 逻辑属性 | 说明 |

| --- | --- | --- |

| margin-left | margin-inline-start | 行首外边距 |

| margin-right | margin-inline-end | 行尾外边距 |

| padding-left | padding-inline-start | 行首内边距 |

| padding-right | padding-inline-end | 行尾内边距 |

| left | inset-inline-start | 行首定位 |

| right | inset-inline-end | 行尾定位 |

| text-align: left | text-align: start | 文本靠行首 |

| border-left | border-inline-start | 行首边框 |

| top-left 圆角 | border-start-start-radius | 块首行首圆角 |

| width | inline-size | 行向尺寸 |

| height | block-size | 块向尺寸 |

cssCode
/* 一套代码同时适配 LTR 和 RTL */
.card {
  padding-inline-start: 16px;   /* LTR 左内边距 / RTL 右内边距 */
  padding-inline-end: 8px;
  margin-inline-start: auto;    /* 靠行尾对齐 */
  border-inline-start: 3px solid #3b82f6;
  text-align: start;
  border-start-start-radius: 8px;
}

/* 只在特定方向生效用 :dir() 伪类 */
.icon:dir(rtl) {
  transform: scaleX(-1);  /* RTL 下镜像图标 */
}

图标镜像

概念:方向性图标(箭头、返回、进度、聊天气泡)在 RTL 下需要水平镜像;非方向性图标(搜索、设置、头像)不镜像。图标(勾选、加号)也不镜像。

cssCode
/* 需要镜像的方向性图标 */
[dir='rtl'] .icon-back,
[dir='rtl'] .icon-arrow-right,
[dir='rtl'] .icon-send {
  transform: scaleX(-1);
}
/* 数字、logo、播放键等绝不镜像 */

方向切换实践与 Tailwind RTL

typescriptCode
// Next.js 根布局按 locale 设置 dir
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];

function getDir(locale: string): 'rtl' | 'ltr' {
  return RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
}
// <html lang={locale} dir={getDir(locale)}>

Tailwind CSS v3.3+ 内置逻辑属性工具类和 rtl:/ltr: 变体:

htmlCode
<!-- ps-4 = padding-inline-start,me-2 = margin-inline-end,自动适配方向 -->
<div class="ps-4 pe-2 ms-auto text-start">
  <!-- rtl: 变体只在 RTL 下应用 -->
  <svg class="rtl:-scale-x-100" />
  <span class="ms-2">继续</span>
</div>

某中东市场 App 复盘:改造前用了 300+ 处 ml-/mr-/left-/right- 物理类,切阿拉伯语时布局全错。全量替换成 ms-/me-/ps-/pe- 逻辑类并给方向图标加 rtl:-scale-x-100 后,同一套组件同时支持 LTR/RTL,RTL 相关 bug 从 40+ 降到 3 个。

翻译工作流与协作

代码里写完 t('key') 只是开始。真正的挑战是如何让几十上百个 key、几种语言、多个译员协作起来不出错。

翻译管理平台:Crowdin / Lokalise / Tolgee

概念:翻译管理系统(TMS)提供在线编辑界面、翻译记忆库、术语库、进度看板,让译员不碰代码就能翻译,并通过 API/CLI 与代码仓库双向同步。

| 平台 | 定位 | 特点 |

| --- | --- | --- |

| Crowdin | 老牌全能 | 生态广、集成多、开源项目免费 |

| Lokalise | 商业化强 | UI 现代、协作与 QA 检查完善 |

| Tolgee | 开发者友好 | 开源可自托管、支持应用内可视化编辑 |

yamlCode
# crowdin.yml —— 声明源文件和译文路径映射
project_id: '123456'
api_token_env: CROWDIN_TOKEN
files:
  - source: /messages/en.json
    translation: /messages/%two_letters_code%.json
    # en.json 为源,翻译回来自动落到 zh.json/ja.json/ar.json
bashCode
crowdin upload sources         # 上传源文案给译员
crowdin download               # 拉回已翻译内容

Tolgee 的应用内编辑(开发时按住 Alt 点文案即可就地编辑)对快速迭代特别高效。

机器翻译预填与翻译记忆库

概念:新增文案先用机器翻译(DeepL/Google)预填,译员在此基础上校对,比从零翻快很多。翻译记忆库(TM)存储历史译文,遇到相同或相似句子自动复用,保证术语一致并降低成本。

某 SaaS 数据:接入 DeepL 预填 + TM 复用后,2000 条新文案的翻译交付周期从平均 9 天缩短到 3 天,译员成本下降约 55%,因为约 40% 句子命中了记忆库或只需轻微校对。

CI 校验翻译完整性与缺失 key 检测

概念:合并代码前用 CI 自动检查——所有语言的 key 是否和源语言对齐、有没有漏翻、有没有多余的废弃 key、ICU 语法是否合法。

typescriptCode
// scripts/check-i18n.ts —— 校验所有语言与基准 en 对齐
import en from '../messages/en.json';
import zh from '../messages/zh.json';
import ar from '../messages/ar.json';

function flatten(obj: any, prefix = ''): string[] {
  return Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && v !== null
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`]
  );
}

const base = new Set(flatten(en));
const locales = { zh, ar };
let hasError = false;

for (const [lng, msgs] of Object.entries(locales)) {
  const keys = new Set(flatten(msgs));
  const missing = [...base].filter((k) => !keys.has(k));
  const extra = [...keys].filter((k) => !base.has(k));
  if (missing.length) {
    console.error(`[${lng}] 缺失 ${missing.length} 个 key:`, missing);
    hasError = true;
  }
  if (extra.length) {
    console.warn(`[${lng}] 多余 ${extra.length} 个废弃 key:`, extra);
  }
}

process.exit(hasError ? 1 : 0);
yamlCode
# .github/workflows/i18n.yml
- name: Check i18n completeness
  run: npx tsx scripts/check-i18n.ts
# 有语言缺 key 就红灯挡住合并

伪本地化(pseudo-localization)测试

概念:伪本地化在开发阶段把文案自动转成加长、加重音符号的"假语言",无需真正翻译就能暴露三类问题:硬编码文案(不会被转换,一眼看出)、文本溢出(转换后变长,布局撑破)、字符编码问题。

typescriptCode
// 伪本地化转换:加重音 + 加长 30% + 包裹方括号
function pseudoLocalize(text: string): string {
  const map: Record<string, string> = {
    a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú',
    A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú',
  };
  const accented = text.replace(/[aeiouAEIOU]/g, (c) => map[c] || c);
  const padding = '~'.repeat(Math.ceil(text.length * 0.3));
  return `[${accented}${padding}]`;
}

pseudoLocalize('Save Changes');
// "[Sávé Chángés~~~]" —— 变长了,若按钮此时溢出说明布局不够弹性
// 若界面上还有纯英文没被方括号包住,那就是硬编码漏翻

微软、Netflix 等都在 CI 里跑伪本地化视觉回归。某团队引入后,上线前发现了 60+ 处硬编码文案和 15 处德语(普遍比英语长 30%)会溢出的按钮。

性能深入

翻译资源的加载策略直接影响首屏和交互体验。

bundle 拆分与按路由懒加载命名空间

概念:把翻译按命名空间拆分,配合路由级代码分割,只在进入某页面时加载它的命名空间。避免把全站文案打进主 bundle。

typescriptCode
// next-intl 里按 namespace 拆分消息文件,动态导入
export default getRequestConfig(async ({ requestLocale }) => {
  const locale = (await requestLocale) ?? 'en';
  return {
    locale,
    // 只导入当前请求需要的命名空间,而非整包
    messages: {
      common: (await import(`../messages/${locale}/common.json`)).default,
    },
  };
});

服务端只下发所需语言

概念:SPA 常犯的错误是把所有语言的 JSON 都打进 bundle,用户只用一种语言却下载了全部。正确做法是服务端根据用户语言只下发那一种。

typescriptCode
// 反例:所有语言静态 import,全部进包
import en from './en.json';
import zh from './zh.json';
import ja from './ja.json';  // 用户是中文用户也白下载了 en/ja

// 正例:动态 import,只加载当前语言
async function loadLocale(lng: string) {
  return (await import(`./locales/${lng}.json`)).default;
}

翻译文件体积对比与首屏 FOUC 处理

FOUC/语言闪烁:SSR 应用若服务端渲染了默认语言、客户端再切成用户语言,用户会看到文案先闪一下英文再变中文(Flash Of Untranslated Content)。解决办法是服务端就确定并渲染正确语言(App Router + next-intl 天然做到),SPA 则在渲染前同步读取语言偏好、必要时用 Suspense 挡住直到翻译就绪。

| 加载策略 | 首屏 JS 中翻译体积 | 首屏时间影响 | 适用场景 |

| --- | --- | --- | --- |

| 全语言全量打包 | 380KB(8 语言) | 慢,阻塞 | 反模式,避免 |

| 单语言全量 | 48KB | 中等 | 小型应用 |

| 单语言 + 命名空间懒加载 | 8KB(仅首屏 ns) | 快 | 中大型应用推荐 |

| RSC 服务端翻译 | 0KB(不进客户端) | 最快 | Next.js App Router |

某电商实测:从"单语言全量 48KB"改为"命名空间懒加载首屏仅 8KB",移动端首屏 TTI 改善约 180ms。进一步把静态文案迁到 RSC 服务端渲染后,这部分翻译完全不进客户端 bundle,交互相关 JS 再降 22KB。

类型安全 i18n

概念:默认 t('some.key') 里的 key 是普通字符串,写错、拼错、用了已删除的 key 都要运行时才暴露。类型安全 i18n 用 TypeScript 让 key 变成有约束的字面量类型,写错立刻编译报错,还能自动补全。

i18next 类型增强(声明合并)

typescriptCode
// types/i18next.d.ts —— 用声明合并把翻译资源类型注入 i18next
import 'i18next';
import type common from '../public/locales/en/common.json';
import type home from '../public/locales/en/home.json';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      home: typeof home;
    };
  }
}
typescriptCode
const { t } = useTranslation('common');
t('navigation.home');      // OK,且有自动补全
t('navigation.hom');       // 编译错误:不存在的 key
t('home:hero.title');      // OK,跨命名空间也有类型检查

next-intl 与 typesafe-i18n

next-intl 支持通过全局声明约束 messages 类型:

typescriptCode
// global.d.ts
import type messages from './messages/en.json';

declare global {
  interface IntlMessages extends Messages {}
  type Messages = typeof messages;
}
// 之后 useTranslations('...') 的 key 都受类型检查

typesafe-i18n 更进一步,从翻译文件生成完整的类型化 API,连插值参数的类型都检查:

typescriptCode
// 若 key 定义为 "greeting": "你好 {name:string}!"
// 生成的 API 会强制要求传 name 且必须是 string
LL.greeting({ name: 'Alice' });   // OK
LL.greeting({});                  // 编译错误:缺少参数 name
LL.greeting({ name: 123 });       // 编译错误:类型不匹配

类型安全的收益随规模放大:某 5 万行、含 1800 个翻译 key 的项目引入 i18next 类型增强后,重构删改 key 时编译期就能定位全部引用点,"key 拼错/漏改导致线上显示原始 key"的线上事故从每季度 3-4 起降到 0。

真实案例复盘

案例一:跨境电商 8 语言上线

某跨境电商从单语言扩展到 8 语言(en/zh/ja/ko/de/fr/es/ar),技术栈 Next.js App Router + next-intl。

| 指标 | 数据 |

| --- | --- |

| 支持语言 | 8 种(含 RTL 阿拉伯语) |

| 翻译 key 规模 | 约 2400 条,拆 14 个命名空间 |

| 翻译文件总体积 | 单语言约 96KB,8 语言共 760KB |

| 首屏客户端翻译体积 | 从全量 96KB 降到懒加载 11KB |

| 首屏 TTI 改善 | 约 210ms |

| 上线周期 | 翻译 + 联调 6 周,其中 RTL 适配占 2 周 |

关键决策:静态文案走 RSC 服务端渲染彻底移出客户端 bundle;商品详情等动态内容按命名空间懒加载;RTL 用 CSS 逻辑属性 + Tailwind rtl: 变体统一处理;CI 加翻译完整性校验和伪本地化视觉回归。

案例二:B2B SaaS 类型安全改造

某 B2B SaaS(React SPA + i18next,4 语言,1800 key)线上频繁出现"界面显示原始 key"的问题,根因是重构时改了 key 名但漏改引用。

引入 i18next 声明合并类型增强 + CI 完整性校验后:编译期即可拦截错误 key;重构可安全批量改名;缺 key 在 CI 红灯拦截。改造后一个季度内相关线上事故从 3-4 起降到 0,翻译相关的 code review 返工也明显减少。

常见坑汇总

| 坑 | 现象 | 正确做法 |

| --- | --- | --- |

| 硬编码文案 | 切语言后部分文字不变 | 所有用户可见文案走 t(),用伪本地化扫出漏网之鱼 |

| 字符串拼接 | 语序错乱、无法翻译整句 | 用带占位符的完整句子或 Trans 组件,绝不拆句拼接 |

| 复数误用 | 俄语/阿语数字后名词形式错 | 按 CLDR 类别填全复数分支,交母语译员校对 |

| 日期时区错误 | 用户看到的时间差几小时 | UTC 存储,展示时按用户时区用 Intl 换算 |

| 货币浮点运算 | 金额出现 0.30000004 | 以分为单位整数存储运算,展示才格式化 |

| RTL 遗漏 | 阿语布局镜像错乱 | 用 CSS 逻辑属性、方向图标镜像、dir 自动检测 |

| 动态拼接 key | 提取工具扫不到、打包丢文案 | key 用静态字面量,动态映射用显式白名单对象 |

| SSR 语言闪烁 | 先闪英文再变中文 | 服务端确定语言并直出,RSC 优先 |

| 翻译缺失无兜底 | 显示原始 key 给用户 | 配 fallbackLng + saveMissing 收集缺失 key |

动态 key 的正确写法

typescriptCode
// 反例:提取工具无法静态分析,打包可能丢失
t(`status.${order.status}`);

// 正例:用显式映射,key 全部是静态字面量可被提取
const STATUS_KEYS = {
  pending: 'status.pending',
  shipped: 'status.shipped',
  delivered: 'status.delivered',
} as const;
t(STATUS_KEYS[order.status]);

主流库选型对比

| 维度 | i18next | react-intl | next-intl | Lingui |

| --- | --- | --- | --- | --- |

| 生态定位 | 全框架通用、插件最多 | FormatJS、格式化强 | Next.js 专用、App Router 一等公民 | 编译优先、体积小 |

| 运行时体积 | 约 40KB(含 react 绑定) | 约 50KB | 约 15KB | 约 5KB(编译后) |

| ICU 支持 | 需 i18next-icu 插件 | 原生一等公民 | 原生支持 | 原生支持 |

| 复数/性别 | 后缀语法或 ICU | ICU 完整 | ICU 完整 | ICU 完整 |

| SSR/RSC | 支持,配置略繁 | 支持 | 最佳,专为 App Router 设计 | 支持 |

| 类型安全 | 声明合并增强 | 中等 | 内置全局类型 | 编译期生成类型 |

| 消息提取 | i18next-scanner | @formatjs/cli | 复用 FormatJS 工具 | @lingui/cli 内置 |

| 学习曲线 | 中等 | 中等偏高 | 低(Next.js 用户) | 中等 |

| 适用场景 | 通用、多框架、插件需求多 | 格式化重、跨端 | Next.js 项目首选 | 极致体积、编译工作流 |

选型建议一句话:Next.js App Router 项目直接用 next-intl;多框架或需要丰富插件(后端加载、语言检测、缓存)选 i18next;格式化需求极重且跨 React Native 选 react-intl;极度在意包体积、能接受编译工作流选 Lingui。

总结

| 主题 | 核心要点 | 关键工具/API |

| --- | --- | --- |

| 库选型 | 按框架和需求选,Next.js 优先 next-intl | i18next / react-intl / next-intl / Lingui |

| 命名空间 | 按模块拆分 + 按需加载降首屏体积 | i18next ns、动态 import |

| 消息格式 | 复杂场景用 ICU,跨库通用 | ICU MessageFormat、i18next-icu |

| 富文本 | 整句翻译,标签映射回组件 | Trans 组件、components 映射 |

| 复数 | 按 CLDR 6 类别填全,母语校对 | Intl.PluralRules、ICU plural |

| 日期时间 | UTC 存储,按时区展示 | Intl.DateTimeFormat、RelativeTimeFormat、Temporal |

| 数字货币 | 金额用整数运算,Intl 格式化 | Intl.NumberFormat、notation compact/unit |

| 排序分词 | 按语言规则,不用默认字典序 | Intl.Collator、Intl.Segmenter、Intl.ListFormat |

| RTL | 逻辑属性 + 图标镜像 + dir 自动 | CSS logical props、Tailwind rtl: |

| 工作流 | TMS 协作 + CI 校验 + 伪本地化 | Crowdin/Lokalise/Tolgee、pseudo-localization |

| 性能 | 只下发所需语言、RSC 服务端翻译 | 命名空间懒加载、动态 import |

| 类型安全 | key 字面量类型化,编译期拦错 | 声明合并、typesafe-i18n |

| 常见坑 | 硬编码、拼接、时区、RTL、动态 key | 静态 key 映射、fallback、saveMissing |

国际化不是"翻译几句话"的附加功能,而是贯穿架构、组件、构建、协作全流程的系统工程。做好它的关键在于:文案与代码分离且可静态提取、格式化交给 Intl 而非手写、复数与方向交给规则而非直觉、协作与校验交给工具而非人工。前期把这套基础设施搭对,后续新增一种语言就只是提交一份翻译文件的成本;反之,把国际化当补丁事后打,则每加一种语言都是一次痛苦的重构。

无障碍与国际化的交叉

概念:国际化和无障碍(a11y)高度相关但常被割裂对待。屏幕阅读器依赖正确的 lang 属性来选择发音引擎,方向属性影响朗读顺序,aria 标签同样需要翻译。

lang 属性必须准确

为什么重要:如果页面 lang="en" 但内容是中文,屏幕阅读器会用英文发音引擎读中文,结果是完全无法理解的乱码音。混合语言页面还需要在局部标注 lang。

typescriptCode
// 根元素设置主语言(Next.js 已在 layout 处理)
// <html lang="zh" dir="ltr">

// 页面内嵌入其他语言片段时局部标注
function Quote() {
  return (
    <blockquote>
      <p>如乔布斯所说:</p>
      {/* 这段英文需要标注 lang,否则屏幕阅读器用中文引擎读英文 */}
      <p lang="en">Stay hungry, stay foolish.</p>
    </blockquote>
  );
}

aria 标签也要翻译

常见坑:开发者常翻译了可见文案,却漏了 aria-label、aria-description、alt 等辅助文本。视力正常用户看不到问题,屏幕阅读器用户却听到的是硬编码英文。

typescriptCode
import { useTranslation } from 'react-i18next';

function IconButton() {
  const { t } = useTranslation();
  return (
    <button
      // aria-label 必须走翻译,不能硬编码
      aria-label={t('actions.close')}
      // 图片 alt 同样要翻译
    >
      <img src="/close.svg" alt={t('actions.close')} />
    </button>
  );
}

动态内容变化的朗读

带 aria-live 的区域在语言切换或数据更新时会被朗读,翻译要保证语义完整,避免只朗读一个数字而无上下文:

typescriptCode
function LiveCartCount({ count }: { count: number }) {
  const { t } = useTranslation();
  return (
    <div aria-live="polite" aria-atomic="true">
      {/* 朗读完整句"购物车有 3 件商品"而非仅"3" */}
      {t('cart.liveCount', { count })}
    </div>
  );
}

语言持久化与 URL 策略深入

语言选择的存储位置和 URL 设计直接影响 SEO、分享体验和用户预期。

三种 URL 策略对比

| 策略 | 示例 | SEO | 优点 | 缺点 |

| --- | --- | --- | --- | --- |

| 路径前缀 | example.com/zh/about | 最佳 | 每语言独立 URL、可被索引、易分享 | 需要路由改造 |

| 子域名 | zh.example.com | 好 | 可独立部署、地域 CDN | 运维成本高、cookie 隔离 |

| 查询参数 | example.com/about?lang=zh | 较差 | 实现简单 | 易被判重复内容、分享易丢参数 |

结论:面向公开搜索的内容站/电商优先用路径前缀(配合 hreflang)。纯内部后台、无 SEO 需求的应用可用查询参数或纯 cookie/localStorage 存储。

持久化的优先级链

概念:确定用户语言时应按优先级依次尝试多个来源,找到即用。推荐链路:URL 显式指定 > 用户已保存偏好 > 浏览器 Accept-Language > 站点默认。

typescriptCode
function resolveLocale(
  urlLocale: string | null,
  savedLocale: string | null,
  acceptLanguage: string | null,
  supported: string[],
  fallback: string
): string {
  // 1. URL 显式指定优先级最高(用户主动选择或分享链接)
  if (urlLocale && supported.includes(urlLocale)) return urlLocale;
  // 2. 用户此前保存的偏好
  if (savedLocale && supported.includes(savedLocale)) return savedLocale;
  // 3. 浏览器语言,取第一个受支持的
  if (acceptLanguage) {
    const preferred = acceptLanguage
      .split(',')
      .map((s) => s.split(';')[0].trim().split('-')[0]);
    const match = preferred.find((l) => supported.includes(l));
    if (match) return match;
  }
  // 4. 站点默认
  return fallback;
}

关键坑:用户手动切换语言后必须持久化并让后续访问尊重该选择——不要每次都用浏览器语言覆盖用户的显式选择。next-intl 的 middleware 会把用户选择写入 NEXT_LOCALE cookie,后续请求优先读它。

国际化测试进阶

除了前面的单元测试,成熟项目还需要覆盖视觉回归和端到端多语言测试。

参数化测试所有语言

typescriptCode
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';

const LOCALES = ['en', 'zh', 'ja', 'ar'];

describe.each(LOCALES)('Navigation in %s', (locale) => {
  beforeEach(() => i18n.changeLanguage(locale));

  it('renders without missing keys', () => {
    render(
      <I18nextProvider i18n={i18n}>
        <Navigation />
      </I18nextProvider>
    );
    // 断言不出现原始 key(说明有漏翻)
    expect(screen.queryByText(/navigation\./)).not.toBeInTheDocument();
  });
});

用 Playwright 做多语言 E2E 与视觉回归

typescriptCode
import { test, expect } from '@playwright/test';

const locales = ['en', 'zh', 'ar'];

for (const locale of locales) {
  test(`homepage renders correctly in ${locale}`, async ({ page }) => {
    await page.goto(`/${locale}`);
    // 断言 html lang 与 dir 正确
    await expect(page.locator('html')).toHaveAttribute('lang', locale);
    const expectedDir = locale === 'ar' ? 'rtl' : 'ltr';
    await expect(page.locator('html')).toHaveAttribute('dir', expectedDir);
    // 视觉回归:与基准截图比对,发现文本溢出、RTL 错乱
    await expect(page).toHaveScreenshot(`home-${locale}.png`, {
      maxDiffPixelRatio: 0.01,
    });
  });
}

缺失 key 的运行时收集

typescriptCode
// 开发/预发环境开启 saveMissing,把缺失 key 上报
i18n.init({
  saveMissing: true,
  missingKeyHandler: (lngs, ns, key) => {
    // 上报到日志系统,便于补翻,绝不让用户看到原始 key
    console.warn(`[i18n] missing: ${lngs.join(',')}/${ns}:${key}`);
    fetch('/api/i18n-missing', {
      method: 'POST',
      body: JSON.stringify({ lngs, ns, key }),
    });
  },
});

把这几层测试接入 CI 后,多语言项目在合并前就能拦住漏翻、文本溢出、方向错乱三类高频缺陷,把国际化质量从"上线后被用户发现"提前到"提交时被流水线拦截"。

国际化不是"翻译几句话"的附加功能,而是贯穿架构、组件、构建、协作全流程的系统工程。前期把基础设施搭对,后续新增语言只是提交一份翻译文件的成本。