起因
要把一个英文站点国际化到中文 + 日文。
"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 提案推动者)维护。
踩过的坑
-
复数规则用错语法:英文写
{count} comments而不是 ICU
{count, plural, ...}。后者才能让翻译灵活。 -
直接 hard-code 日期格式:
new Date().toLocaleDateString('en')
只能英文。永远FormattedDate才跟随当前 locale。 -
RTL 语言(阿拉伯 / 希伯来)布局:仅翻字符串不够,CSS 也要
适配。dir="rtl"+logical properties(margin-inline-start
而非 margin-left)。 -
lazy load 时第一次渲染缺译文:fallback 显示
[msg.greeting]
是糟糕体验。加载完成前用 default locale 兜底,或显示骨架。 -
翻译里有 HTML 注入风险:译员误打了
<script>进去会被解析。
ICU<tag>必须在代码里映射;不在映射里的会被原样输出(HTML 实体
escape)。
登录后参与评论。