Intl.PluralRules API

如何在 JavaScript 中正确处理复数形式

简介

复数化是根据数量显示不同文本的过程。在英文中,通常会针对单个项目显示“1 item”,多个项目则显示“2 items”。大多数开发者会用一个简单的条件判断,除了数量为 1 以外都加上“s”。

这种方法在非英语语言中就不适用了。例如,波兰语针对 1、2-4 和 5 及以上分别使用不同的形式。阿拉伯语有零、一、二、少(3-10)、多(11-99)和其他(100+)六种形式。威尔士语也有六种不同的复数形式。即使在英语中,像“person”到“people”这样的不规则复数也需要特殊处理。

Intl.PluralRules API 通过为任意数字和任意语言提供复数类别,解决了这个问题。你只需提供一个数量,API 就会根据目标语言的规则告诉你应该使用哪种形式。这样,你可以编写具备国际化能力的代码,无需手动编码每种语言的复数规则,也能正确处理多语言场景。

各语言如何处理复数形式

不同语言在表达数量时有很大差异。英语有两种形式:数量为 1 时用单数,其他情况用复数。这看似简单,但遇到其他复数系统的语言时就会变得复杂。

俄语和波兰语有三种形式。单数用于 1,特殊形式用于以 2、3、4 结尾的数字(但不包括 12、13、14),其余所有数量使用第三种形式。

阿拉伯语有六种形式:零、一、二、少(3-10)、多(11-99)和其他(100+)。威尔士语同样有六种形式,但数值区间不同。

某些语言,如中文和日语,完全不区分单数和复数。无论数量多少,形式都相同。

Intl.PluralRules API 通过基于 Unicode CLDR 复数规则的标准化类别名称,抽象了这些差异。六个类别分别是:zero、one、two、few、many 和 other。并非所有语言都使用这六个类别。英语只用 one 和 other。阿拉伯语则全部用到六类。

为某个语言环境创建 PluralRules 实例

Intl.PluralRules 构造函数接受一个语言环境标识符,并返回一个对象,可用于判断某个数字属于哪个复数类别。

const enRules = new Intl.PluralRules('en-US');

每个语言环境只需创建一个实例并复用。每次复数化都新建实例会造成资源浪费。建议将实例存储在变量中或使用缓存机制。

默认类型为 cardinal(基数),用于计数对象。你也可以通过传递 options 对象来创建序数(ordinal)规则。

const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

基数规则适用于“1 个苹果、2 个苹果”这样的计数。序数规则适用于“第 1 名、第 2 名”这样的顺序。

使用 select() 获取数字对应的复数类别

select() 方法接受一个数字,返回该数字在目标语言中属于哪个复数类别。

const enRules = new Intl.PluralRules('en-US');

enRules.select(0);  // 'other'
enRules.select(1);  // 'one'
enRules.select(2);  // 'other'
enRules.select(5);  // 'other'

返回值始终是六个类别名称之一:zero、one、two、few、many 或 other。英语只会返回 one 和 other,因为只用到这两种形式。

对于规则更复杂的阿拉伯语,会用到全部六个类别:

const arRules = new Intl.PluralRules('ar-EG');

arRules.select(0);   // 'zero'
arRules.select(1);   // 'one'
arRules.select(2);   // 'two'
arRules.select(6);   // 'few'
arRules.select(18);  // 'many'
arRules.select(100); // 'other'

将类别映射到本地化字符串

API 只会告知适用的类别。实际的文本内容需要由你提供。请将每个类别对应的文本存储在一个 Map 或对象中,类别名称作为键。

const enRules = new Intl.PluralRules('en-US');
const enForms = new Map([
  ['one', 'item'],
  ['other', 'items'],
]);

function formatItems(count) {
  const category = enRules.select(count);
  const form = enForms.get(category);
  return `${count} ${form}`;
}

formatItems(1);  // '1 item'
formatItems(5);  // '5 items'

这种模式将逻辑与数据分离。PluralRules 实例负责处理规则,Map 用于存储翻译,函数则将两者结合。

对于类别更多的语言,只需在 Map 中添加更多条目:

const arRules = new Intl.PluralRules('ar-EG');
const arForms = new Map([
  ['zero', 'عناصر'],
  ['one', 'عنصر واحد'],
  ['two', 'عنصران'],
  ['few', 'عناصر'],
  ['many', 'عنصرًا'],
  ['other', 'عنصر'],
]);

function formatItems(count) {
  const category = arRules.select(count);
  const form = arForms.get(category);
  return `${count} ${form}`;
}

务必为该语言使用的每个类别都提供条目。缺失的类别会导致查找失败。如果不确定某种语言使用哪些类别,可以查阅 Unicode CLDR 复数规则,或通过 API 针对不同数字进行测试。

处理小数和分数数量

select() 方法支持小数。英语中,即使是 0 到 2 之间的小数,也会被视为复数。

const enRules = new Intl.PluralRules('en-US');

enRules.select(1);    // 'one'
enRules.select(1.0);  // 'one'
enRules.select(1.5);  // 'other'
enRules.select(0.5);  // 'other'

其他语言对小数有不同的规则。有些语言将所有小数视为复数,而有些则根据小数部分采用更复杂的规则。

如果 UI 需要显示如“1.5 GB”或“2.7 英里”这样的分数量,请直接将小数传递给 select()。除非 UI 需要四舍五入显示,否则不要提前四舍五入。

格式化序数,如 1st、2nd、3rd

序数用于表示位置或排名。英语通过添加后缀来构成序数:1st、2nd、3rd、4th。这个模式并不是简单地“加 th”,因为 1、2、3 有特殊形式,且以 1、2、3 结尾的数字(如 21st、22nd、23rd)也有特殊规则,但以 11、12、13 结尾的数字(如 11th、12th、13th)则例外。

当你指定 type: 'ordinal' 时,Intl.PluralRules API 会自动处理这些规则。

const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

enOrdinalRules.select(1);   // 'one'
enOrdinalRules.select(2);   // 'two'
enOrdinalRules.select(3);   // 'few'
enOrdinalRules.select(4);   // 'other'
enOrdinalRules.select(11);  // 'other'
enOrdinalRules.select(21);  // 'one'
enOrdinalRules.select(22);  // 'two'
enOrdinalRules.select(23);  // 'few'

将类别映射到序数后缀:

const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });
const enOrdinalSuffixes = new Map([
  ['one', 'st'],
  ['two', 'nd'],
  ['few', 'rd'],
  ['other', 'th'],
]);

function formatOrdinal(n) {
  const category = enOrdinalRules.select(n);
  const suffix = enOrdinalSuffixes.get(category);
  return `${n}${suffix}`;
}

formatOrdinal(1);   // '1st'
formatOrdinal(2);   // '2nd'
formatOrdinal(3);   // '3rd'
formatOrdinal(4);   // '4th'
formatOrdinal(11);  // '11th'
formatOrdinal(21);  // '21st'

其他语言有完全不同的序数系统。例如,法语用 "1er" 表示第一,其余用 "2e"。西班牙语的序数有性别区分。API 提供类别,你需要提供本地化的形式。

使用 selectRange() 处理区间

selectRange() 方法用于确定数字区间的复数类别,例如 "1-5 个项目" 或 "10-20 个结果"。某些语言对区间的复数规则与单个数字不同。

const enRules = new Intl.PluralRules('en-US');

enRules.selectRange(1, 5);   // 'other'
enRules.selectRange(0, 1);   // 'other'

在英语中,区间几乎总是用复数,即使区间从 1 开始。其他语言的区间规则更为复杂。

const slRules = new Intl.PluralRules('sl');

slRules.selectRange(102, 201);  // 'few'

const ptRules = new Intl.PluralRules('pt');

ptRules.selectRange(102, 102);  // 'other'

在 UI 中明确显示区间时,使用 selectRange()。对于单个数量,使用 select()。

结合 Intl.NumberFormat 实现本地化数字显示

复数形式通常与格式化数字一起出现。请使用 Intl.NumberFormat 按照本地习惯格式化数字,然后用 Intl.PluralRules 选择正确的文本。

const locale = 'en-US';
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ['one', 'item'],
  ['other', 'items'],
]);

function formatCount(count) {
  const formattedNumber = numberFormat.format(count);
  const category = pluralRules.select(count);
  const form = forms.get(category);
  return `${formattedNumber} ${form}`;
}

formatCount(1);      // '1 item'
formatCount(1000);   // '1,000 items'
formatCount(1.5);    // '1.5 items'

以德语为例,千位分隔符用点,小数分隔符用逗号:

const locale = 'de-DE';
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ['one', 'Artikel'],
  ['other', 'Artikel'],
]);

function formatCount(count) {
  const formattedNumber = numberFormat.format(count);
  const category = pluralRules.select(count);
  const form = forms.get(category);
  return `${formattedNumber} ${form}`;
}

formatCount(1);      // '1 Artikel'
formatCount(1000);   // '1.000 Artikel'
formatCount(1.5);    // '1,5 Artikel'

这种模式确保数字格式和文本形式都符合该语言环境下用户的预期。

必要时显式处理零的情况

不同语言对零的复数化处理方式不同。英语通常用复数形式:"0 items"、"0 results"。有些语言对零用单数形式,另一些则有专门的零类别。

Intl.PluralRules API 会根据语言规则返回零对应的类别。在英语中,零返回 'other',即复数形式:

const enRules = new Intl.PluralRules('en-US');

enRules.select(0);  // 'other'

在阿拉伯语中,零有其独立的类别:

const arRules = new Intl.PluralRules('ar-EG');

arRules.select(0);  // 'zero'

您的文本应考虑到这一点。对于英语,建议显示“No items”而不是“0 items”,以提升用户体验。在调用复数规则前处理此逻辑:

function formatItems(count) {
  if (count === 0) {
    return 'No items';
  }
  const category = enRules.select(count);
  const form = enForms.get(category);
  return `${count} ${form}`;
}

对于阿拉伯语,请在翻译中为零提供专门的形式:

const arForms = new Map([
  ['zero', 'لا توجد عناصر'],
  ['one', 'عنصر واحد'],
  ['two', 'عنصران'],
  ['few', 'عناصر'],
  ['many', 'عنصرًا'],
  ['other', 'عنصر'],
]);

这样既尊重了各语言的语言习惯,也便于您自定义零的情况以优化用户体验。

复用 PluralRules 实例以提升性能

创建 PluralRules 实例时需要解析 locale 并加载复数规则数据。每个 locale 只需创建一次实例,不要在每次函数调用或渲染周期中重复创建。

// Good: create once, reuse
const enRules = new Intl.PluralRules('en-US');
const enForms = new Map([
  ['one', 'item'],
  ['other', 'items'],
]);

function formatItems(count) {
  const category = enRules.select(count);
  const form = enForms.get(category);
  return `${count} ${form}`;
}

如果支持多个 locale,请为每个 locale 创建实例,并存储在 Map 或缓存中:

const rulesCache = new Map();

function getPluralRules(locale) {
  if (!rulesCache.has(locale)) {
    rulesCache.set(locale, new Intl.PluralRules(locale));
  }
  return rulesCache.get(locale);
}

const rules = getPluralRules('en-US');

这种模式可以将初始化成本分摊到多次调用中。

浏览器支持与兼容性

自 2019 年起,所有现代浏览器均已支持 Intl.PluralRules,包括 Chrome 63+、Firefox 58+、Safari 13+ 和 Edge 79+。Internet Explorer 不支持该特性。

对于面向现代浏览器的应用,可以直接使用 Intl.PluralRules,无需 polyfill。如果需要支持旧版浏览器,可通过 npm 上的 intl-pluralrules 等包获取 polyfill。

selectRange() 方法较新,支持范围略有限。该方法在 Chrome 106+、Firefox 116+、Safari 15.4+ 和 Edge 106+ 可用。如需支持旧版浏览器,请检查 selectRange() 的兼容性。

避免在逻辑中硬编码复数形式

不要通过判断 count 并在代码中分支来选择复数形式。这种方式无法适应拥有多于两种复数形式的语言,并且会将您的逻辑与英语规则耦合。

// Avoid this pattern
function formatItems(count) {
  if (count === 1) {
    return `${count} item`;
  }
  return `${count} items`;
}

使用 Intl.PluralRules 和数据结构来存储不同形式。这样可以让你的代码与语言无关,并且只需提供新的翻译即可轻松添加新语言。

// Prefer this pattern
const rules = new Intl.PluralRules('en-US');
const forms = new Map([
  ['one', 'item'],
  ['other', 'items'],
]);

function formatItems(count) {
  const category = rules.select(count);
  const form = forms.get(category);
  return `${count} ${form}`;
}

这种模式在任何语言中都可以相同地工作。只需更换规则实例和 forms Map。

针对多种语言环境和边界情况进行测试

复数规则存在一些在仅用英语测试时容易忽略的边界情况。请至少用一种使用两种以上复数形式的语言(如波兰语或阿拉伯语)测试你的复数逻辑。

测试会触发不同类别的计数:

  • 少量(阿拉伯语中为 3-10)
  • 许多(阿拉伯语中为 11-99)
  • 大数(100 及以上)
  • 小数值(0.5、1.5、2.3)
  • 如果你的 UI 会显示,负数

如果你使用序数规则,请测试会触发不同后缀的数字:1、2、3、4、11、21、22、23。这样可以确保你正确处理特殊情况。

及早用多种语言环境进行测试,可以避免后续添加新语言时出现意外情况。同时也能验证你的数据结构是否包含所有必要类别,以及你的逻辑是否能正确处理它们。