CLDR 复数规则与 ICU 消息格式
核心问题:为什么"1 item / 2 items"这样的复数逻辑在不同语言里完全不同?如何用 ICU 消息格式一次性解决?
真实场景
你的购物车显示 {count} 件商品,英文版需要 1 item(单数)和 2 items(复数),阿拉伯语版有 6 种不同形式。错误的复数处理是 i18n 项目中最常见的 bug 来源之一。
为什么复数很复杂
不同语言的复数规则差异极大:
| 语言 | 复数类别 | 规则 |
|---|---|---|
| 中文、日文、韩文 | 1 种(other) | 无复数变化,数字 + 量词 |
| 英语、德语 | 2 种(one, other) | 1 是单数,其他是复数 |
| 法语 | 2 种(one, other) | 0 和 1 是单数 |
| 俄语、波兰语 | 3 种(one, few, many) | 复杂,尾数决定形式 |
| 阿拉伯语 | 6 种(zero, one, two, few, many, other) | 最复杂 |
| 斯洛文尼亚语 | 4 种(one, two, few, other) | 有双数形式 |
CLDR 六种复数类别
Unicode CLDR(Common Locale Data Repository)定义了 6 种复数类别:
| 类别 | 说明 |
|---|---|
zero | 数量为零 |
one | 单数(通常是 1) |
two | 双数(如阿拉伯语、希伯来语) |
few | 少数(如 2-4,视语言而定) |
many | 大数 |
other | 其他情况(所有语言都有) |
主要语言的复数规则
英语(2 种形式):
- one:n = 1(1 item)
- other:其他(0 items, 2 items, 100 items)
中文(1 种形式):
- other:所有数量(0 件商品, 1 件商品, 100 件商品)
俄语(3 种形式):
- one:尾数为 1,但不是 11(21, 31, 41...)
- few:尾数为 2-4,但不是 12-14
- many:尾数为 5-9, 0, 11-14
- other:小数
阿拉伯语(6 种形式):
- zero:n = 0
- one:n = 1
- two:n = 2
- few:n % 100 = 3..10
- many:n % 100 = 11..99
- other:小数和 0.X
Intl.PluralRules 查询复数类别
const enRules = new Intl.PluralRules('en-US');
enRules.select(0); // "other"
enRules.select(1); // "one"
enRules.select(2); // "other"
enRules.select(15); // "other"
const arRules = new Intl.PluralRules('ar');
arRules.select(0); // "zero"
arRules.select(1); // "one"
arRules.select(2); // "two"
arRules.select(5); // "few"
arRules.select(15); // "many"
arRules.select(100); // "other"
const ruRules = new Intl.PluralRules('ru');
ruRules.select(1); // "one"
ruRules.select(2); // "few"
ruRules.select(5); // "many"
ruRules.select(11); // "many"(11 不是 one,虽然尾数是 1)
ruRules.select(21); // "one"(21 是 one)
ICU 消息格式
ICU(International Components for Unicode)消息格式是处理复数、性别、选择等复杂 i18n 场景的标准格式。react-intl 和 next-intl 使用此格式。
基础语法
{变量名, 类型, 格式}
纯文本插值
你好,{name}!
plural:复数
{count, plural,
=0 {没有商品}
one {# 件商品}
other {# 件商品}
}
=0:精确匹配数量 0one、other:CLDR 复数类别#:代表实际数字
完整示例(各语言对比)
// zh-CN.json
{
"cartItems": "{count, plural, =0 {购物车是空的} other {购物车中有 # 件商品}}"
}
// en-US.json
{
"cartItems": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"
}
// ru-RU.json
{
"cartItems": "{count, plural, =0 {Корзина пуста} one {# товар в корзине} few {# товара в корзине} many {# товаров в корзине} other {# товара в корзине}}"
}
// ar-SA.json
{
"cartItems": "{count, plural, =0 {سلة التسوق فارغة} one {منتج واحد في سلة التسوق} two {منتجان في سلة التسوق} few {# منتجات في سلة التسوق} many {# منتجاً في سلة التسوق} other {# منتج في سلة التسوق}}"
}
// 使用(react-intl)
import { useIntl } from 'react-intl';
function CartButton({ count }: { count: number }) {
const intl = useIntl();
return (
<button>
{intl.formatMessage({ id: 'cartItems' }, { count })}
</button>
);
}
// 不同 locale 的输出:
// zh-CN, count=0: "购物车是空的"
// zh-CN, count=3: "购物车中有 3 件商品"
// en-US, count=1: "1 item in your cart"
// en-US, count=3: "3 items in your cart"
// ru-RU, count=2: "2 товара в корзине"
// ru-RU, count=5: "5 товаров в корзине"
select:选择(性别等)
{
"message": "{gender, select, male {他已经} female {她已经} other {该用户已经}} 完成了注册"
}
intl.formatMessage({ id: 'message' }, { gender: 'female' });
// "她已经完成了注册"
selectordinal:序数词
{
"ranking": "您的排名是第 {rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"
}
en-US, rank=1: "您的排名是第 1st"
en-US, rank=2: "您的排名是第 2nd"
en-US, rank=3: "您的排名是第 3rd"
en-US, rank=4: "您的排名是第 4th"
嵌套格式
{
"notification": "{count, plural, =0 {没有新消息} one {你有 # 条来自 {sender} 的新消息} other {你有 # 条来自 {sender} 的新消息}}"
}
在各框架中使用
react-intl / next-intl
// 两个框架的 ICU 消息格式完全相同
// react-intl
import { FormattedMessage, useIntl } from 'react-intl';
// 方式一:组件
<FormattedMessage
id="cartItems"
values={{ count: 3 }}
/>
// 方式二:hook(支持富文本)
const intl = useIntl();
intl.formatMessage({ id: 'cartItems' }, { count: 3 });
// 富文本(标签插值)
<FormattedMessage
id="termsMessage"
values={{
link: (chunks) => <a href="/terms">{chunks}</a>,
bold: (chunks) => <strong>{chunks}</strong>,
}}
/>
// 翻译文件:
// "termsMessage": "点击即表示您同意<link>服务条款</link>和<bold>隐私政策</bold>"
vue-i18n(ICU 模式)
vue-i18n 默认使用自己的复数语法(| 分隔),但也支持 ICU 模式:
// main.ts
import { createI18n } from 'vue-i18n';
const i18n = createI18n({
locale: 'zh-CN',
messages: {
'zh-CN': { /* ICU 格式的消息 */ }
},
// 启用 ICU 消息格式
messageResolver: /* 自定义解析器 */,
});
或安装 @intlify/vue-i18n-loader 和 @formatjs/intl-messageformat:
npm install @formatjs/intl-messageformat
// 手动集成 ICU 解析(适合需要完整 ICU 支持的场景)
import IntlMessageFormat from '@formatjs/intl-messageformat';
const message = new IntlMessageFormat(
'{count, plural, =0 {购物车是空的} other {购物车中有 # 件商品}}',
'zh-CN'
);
message.format({ count: 3 }); // "购物车中有 3 件商品"
数字格式化(ICU 骨架语法)
ICU 消息格式内嵌数字格式化:
{
"price": "价格:{amount, number, ::currency/CNY}",
"discount": "折扣:{percent, number, ::percent}",
"amount": "{value, number, ::.2f}"
}
ICU 骨架(Skeleton)语法参考:
| 骨架 | 效果 |
|---|---|
::currency/USD | 货币格式,美元 |
::percent | 百分比 |
::compact-short | 紧凑格式(1.2K) |
::.2f | 两位小数 |
::sign-always | 始终显示符号(+/-) |
::group-off | 关闭千分位分隔符 |
常见陷阱
陷阱 1:直接字符串拼接
// ❌ 错误
`购物车中有 ${count} 件商品` // 硬编码中文,无法本地化
// ✅ 正确
t('cartItems', { count }) // 使用 ICU plural
陷阱 2:只写 one/other,忽略其他语言的复数形式
// ❌ 为俄语只提供 one/other,忽略 few/many
"cartItems": "{count, plural, one {# товар} other {# товаров}}"
// ✅ 俄语需要完整的 4 种形式
"cartItems": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товара}}"
陷阱 3:混淆 =1 和 one
// =1 是精确匹配(数量恰好是 1)
// one 是 CLDR 的"单数"类别(可能包含 1, 21, 31... 视语言而定)
// 法语:0 和 1 都是 one 类别
// {count, plural, =0 {vide} one {# article} other {# articles}}
// count=0 → "vide"(精确匹配 =0 优先于 one)
// count=1 → "1 article"
陷阱 4:序数词和基数词混淆
plural:基数词(1件, 2件)
selectordinal:序数词(第1名, 第2名)
英语:1st, 2nd, 3rd, 4th(用 selectordinal)
中文:第1名, 第2名(无需区分序数词格式)
下一节:复数规则解决了,现在看货币显示的完整解决方案——包括汇率更新策略和时区陷阱。