CLDR 复数规则与 ICU 消息格式
High Contrast
Dark Mode
Light Mode
Sepia
Forest
4 min read794 words

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 {# 件商品}
}

完整示例(各语言对比)

// 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:混淆 =1one

// =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名(无需区分序数词格式)

下一节:复数规则解决了,现在看货币显示的完整解决方案——包括汇率更新策略和时区陷阱。

货币显示、汇率策略与时区处理