React i18n:用 react-intl 处理复数 / 性别 / 日期 / 嵌入变量

起因

要把一个英文站点国际化到中文 + 日文。
"naive"做法:if (lang === 'zh') return '你有 ' + n + ' 条消息'
但碰到:

  • 复数:英文 "1 message" vs "5 messages";俄文 4 种复数形式
  • 日期:1/2/2024 vs 2024-01-02 vs 2024年1月2日
  • 货币:$1,234.56 vs ¥12,345.6
  • 嵌入组件:'<b>张三</b> 发了 <a href=...>3 条评论</a>' 怎么拆?

ICU MessageFormat 是 Unicode 联盟标准,处理这些场景。react-intl
(FormatJS)是 React 的标准 ICU 实现。

解决方案

npm i react-intl

顶层 Provider

import { IntlProvider } from 'react-intl'

const messages = {
  en: { 'msg.greeting': 'Hello, {name}!' },
  zh: { 'msg.greeting': '你好,{name}!' },
  ja: { 'msg.greeting': '{name}さん、こんにちは!' },
}

function App() {
  const [locale, setLocale] = useState('en')
  return (
    <IntlProvider locale={locale} messages={messages[locale]}>
      <MyApp />
    </IntlProvider>
  )
}

<FormattedMessage>intl.formatMessage

import { FormattedMessage, useIntl } from 'react-intl'

function Greeting({ user }) {
  return (
    <h1>
      <FormattedMessage id="msg.greeting" values={{ name: user.name }} />
    </h1>
  )
}

// 或 hook
function Title({ name }) {
  const intl = useIntl()
  const text = intl.formatMessage({ id: 'msg.greeting' }, { name })
  return <title>{text}</title>
}

ICU MessageFormat 复数

const messages = {
  en: {
    'comments.count': '{count, plural, =0 {No comments} one {1 comment} other {# comments}}'
  },
  zh: {
    'comments.count': '{count, plural, =0 {暂无评论} other {# 条评论}}'
  },
  ru: {
    'comments.count': '{count, plural, =0 {Нет комментариев} one {# комментарий} few {# комментария} many {# комментариев} other {# комментариев}}'
  },
}

<FormattedMessage id="comments.count" values={{ count: 5 }} />

# 自动替换成数字。
不同语言的复数规则:

  • 英文:one (1) / other (其它)
  • 中文:other
  • 俄文:one / few / many / other
  • 阿拉伯:zero / one / two / few / many / other

库里有 CLDR 数据自动应用。

Select(按值选不同文案)

'user.role': '{role, select, admin {管理员} editor {编辑} viewer {访客} other {未知}}'

嵌入 React 组件

const messages = {
  zh: {
    'notif.commented': '<b>{user}</b> 评论了你的 <a>帖子</a>',
  },
}

<FormattedMessage
  id="notif.commented"
  values={{
    user: 'Alice',
    b: chunks => <strong>{chunks}</strong>,
    a: chunks => <a href={`/posts/${id}`}>{chunks}</a>,
  }}
/>
// 输出:<strong>Alice</strong> 评论了你的 <a href="/posts/1">帖子</a>

翻译里的标签会调对应 function。完全保留 React 组件 + 事件 + 链接。

日期 / 时间 / 货币

import { FormattedDate, FormattedTime, FormattedNumber } from 'react-intl'

<FormattedDate value={new Date()} year="numeric" month="long" day="numeric" />
// en: January 15, 2024
// zh: 2024年1月15日
// ja: 2024年1月15日

<FormattedTime value={new Date()} hour="numeric" minute="numeric" />

<FormattedNumber value={1234.5} style="currency" currency="USD" />
// $1,234.50

<FormattedNumber value={1234.5} style="currency" currency="CNY" />
// ¥1,234.50

<FormattedNumber value={0.85} style="percent" />
// 85%

底层 Intl.DateTimeFormat / Intl.NumberFormat,浏览器原生。

相对时间

import { FormattedRelativeTime } from 'react-intl'

<FormattedRelativeTime value={-2} unit="hour" />
// en: 2 hours ago
// zh: 2 小时前

翻译文件管理

每个 locale 一个 JSON:

src/locales/
├── en.json
├── zh.json
└── ja.json
// en.json
{
  "msg.greeting": "Hello, {name}!",
  "comments.count": "{count, plural, =0 {No comments} one {1 comment} other {# comments}}"
}

按需加载:

async function loadMessages(locale: string) {
  return (await import(`./locales/${locale}.json`)).default
}

function App() {
  const [locale, setLocale] = useState('en')
  const [messages, setMessages] = useState({})
  useEffect(() => { loadMessages(locale).then(setMessages) }, [locale])
  return <IntlProvider locale={locale} messages={messages}>...</IntlProvider>
}

不同语言不打进 main bundle,按需 lazy load。

提取翻译 key

npx @formatjs/cli extract 'src/**/*.{ts,tsx}' --out-file lang/en.json --format simple

扫描代码里所有 FormattedMessage / formatMessage 调用,提取 key +
defaultMessage。翻译团队基于这个文件翻译。

用 Crowdin / Lokalise / POEditor

把 en.json 上传 → 译员翻译 → 下载 zh.json / ja.json。
专业翻译工具有翻译记忆 / 协作 / 校对功能。

TypeScript 类型安全

npm i -D @formatjs/cli
// auto-generated.ts
type Messages =
  | 'msg.greeting'
  | 'comments.count'
  // ...

declare module 'react-intl' {
  interface FormattedMessageProps {
    id: Messages
  }
}

写错 id 编译报错。

实战 tip

1. defaultMessage

<FormattedMessage
  id="msg.greeting"
  defaultMessage="Hello, {name}!"
  values={{ name }}
/>

defaultMessage 在 dev 时显示(避免 key 缺失看到 [msg.greeting]),
在 extract 时被收进 source 文件。

2. 不要 concat 字符串

// ❌ 永远不要
<>{intl.formatMessage({ id: 'a' })} {intl.formatMessage({ id: 'b' })}</>

// ✅ 一条 message 一句话
'msg.fullName': '{first} {last}'   // 在 message 里组合

某些语言(日文)语序不一样,concat 永远不能正确翻译。

3. 上下文 (context)

同样英文 "Open" 可能是按钮 / 状态 / 动词,翻译不同:

"button.open": "Open",
"status.open": "Open",

加注释让译员理解:

<FormattedMessage
  id="button.open"
  description="Button to open a file picker"
  defaultMessage="Open"
/>

extract 出来 description 给译员看。

效果

  • 3 种语言(en/zh/ja)全覆盖 + 复数 + 日期 + 货币正确
  • 翻译团队用 Crowdin 协作,开发不参与翻译细节
  • bundle 按 locale 分割,每个 locale 增加 ~20KB
  • 加新语言只需添 JSON + locale 列表 + 设置 default 即可

替代品

  • i18next(react-i18next):更老牌、生态大、社区资源多
  • lingui:Babel macro 转译,运行时几乎无开销
  • next-intl:Next.js App Router 原生集成

i18next 与 react-intl 哪个好争论不断。我选 react-intl 因为 ICU 标准
+ FormatJS 团队(Intl 提案推动者)维护。

踩过的坑

  1. 复数规则用错语法:英文写 {count} comments 而不是 ICU
    {count, plural, ...}。后者才能让翻译灵活。

  2. 直接 hard-code 日期格式new Date().toLocaleDateString('en')
    只能英文。永远 FormattedDate 才跟随当前 locale。

  3. RTL 语言(阿拉伯 / 希伯来)布局:仅翻字符串不够,CSS 也要
    适配。dir="rtl" + logical properties(margin-inline-start
    而非 margin-left)。

  4. lazy load 时第一次渲染缺译文:fallback 显示 [msg.greeting]
    是糟糕体验。加载完成前用 default locale 兜底,或显示骨架。

  5. 翻译里有 HTML 注入风险:译员误打了 <script> 进去会被解析。
    ICU <tag> 必须在代码里映射;不在映射里的会被原样输出(HTML 实体
    escape)。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。