如何为不同语言选择正确的复数形式?

使用 JavaScript 的 Intl.PluralRules,根据语言规则在单数、双数、少量和大量之间选择合适的表达方式

引言

在显示带有数量的文本时,不同的数量需要不同的信息。在英文中,你会写“1 file”,但会写“2 files”。最简单的方法是将数字和单词拼接,并在需要时加上“s”。

function formatFileCount(count) {
  return count === 1 ? `${count} file` : `${count} files`;
}

这种方法有三个问题。首先,它对零的英文表达不准确(“0 files”其实更应该是“no files”)。其次,遇到复杂的复数形式时会出错,比如“1 child, 2 children”或“1 person, 2 people”。第三,也是最关键的,其他语言有完全不同的复数规则,这段代码无法处理。

JavaScript 提供了 Intl.PluralRules 来解决这个问题。该 API 能根据任何语言的 Unicode CLDR 标准,判断任意数字应使用哪种复数形式,这也是全球专业翻译系统采用的标准。

为什么不同语言需要不同的复数形式

英语有两种复数形式。你会写“1 book”和“2 books”。当数量为 1 时和其他数字时,单词会发生变化。

其他语言的规则不同。波兰语根据复杂规则有三种形式。俄语有四种形式。阿拉伯语有六种形式。有些语言对所有数量只用一种形式。

以下是“apple”在不同语言中根据数量变化的例子:

英语:1 apple,2 apples,5 apples,0 apples

波兰语:1 jabłko,2 jabłka,5 jabłek,0 jabłek

俄语:1 яблоко,2 яблока,5 яблок,0 яблок

阿拉伯语:根据数量为零、一个、两个、几个、许多或其他,使用六种不同的形式

Unicode CLDR 明确定义了每种语言在何时使用哪种形式的规则。你无法记住这些规则,也不能将它们硬编码到你的应用程序中。你需要一个了解这些规则的 API。

什么是 CLDR 复数类别

Unicode CLDR 标准定义了六种复数类别,涵盖所有语言:

  • zero:某些语言中用于精确为零的数量
  • one:用于单数形式
  • two:用于有双数形式的语言
  • few:在某些语言中用于少量
  • many:在某些语言中用于较大量或分数
  • other:默认形式,当其他类别不适用时使用

每种语言都会用到 other 类别。大多数语言总共只用两到三种类别。这些类别并不直接对应具体数量。例如,在波兰语中,数字 5 使用 many 类别,0、25 和 1.5 也是如此。

每种语言中,哪些数字对应哪些类别有具体规则。JavaScript 通过 Intl.PluralRules API 处理这种复杂性。

如何确定应使用哪种复数形式

Intl.PluralRules 对象可以确定某个数字在特定语言中属于哪个复数类别。你可以用一个本地化参数创建 PluralRules 对象,然后用数字调用其 select() 方法。

const rules = new Intl.PluralRules('en-US');
console.log(rules.select(0));  // "other"
console.log(rules.select(1));  // "one"
console.log(rules.select(2));  // "other"
console.log(rules.select(5));  // "other"

在英文中,select() 方法对于数字 1 返回 "one",对于其他数字返回 "other"

波兰语使用三种类别,规则更为复杂:

const rules = new Intl.PluralRules('pl-PL');
console.log(rules.select(0));   // "many"
console.log(rules.select(1));   // "one"
console.log(rules.select(2));   // "few"
console.log(rules.select(5));   // "many"
console.log(rules.select(22));  // "few"
console.log(rules.select(25));  // "many"

阿拉伯语使用六种类别:

const rules = new Intl.PluralRules('ar-EG');
console.log(rules.select(0));   // "zero"
console.log(rules.select(1));   // "one"
console.log(rules.select(2));   // "two"
console.log(rules.select(3));   // "few"
console.log(rules.select(11));  // "many"
console.log(rules.select(100)); // "other"

select() 方法会返回一个字符串,用于标识类别。你可以使用该字符串选择要显示的合适消息。

如何将复数类别映射到消息

确定复数类别后,需要选择正确的消息展示给用户。创建一个对象,将每个类别映射到对应的消息,然后使用类别字符串查找消息。

const messages = {
  one: '{count} file',
  other: '{count} files'
};

function formatFileCount(count, locale) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category];
  return message.replace('{count}', count);
}

console.log(formatFileCount(1, 'en-US'));  // "1 file"
console.log(formatFileCount(5, 'en-US'));  // "5 files"

这种模式适用于任何语言。对于波兰语,你需要为该语言使用的三种类别都提供消息:

const messages = {
  one: '{count} plik',
  few: '{count} pliki',
  many: '{count} plików'
};

function formatFileCount(count, locale) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category];
  return message.replace('{count}', count);
}

console.log(formatFileCount(1, 'pl-PL'));   // "1 plik"
console.log(formatFileCount(2, 'pl-PL'));   // "2 pliki"
console.log(formatFileCount(5, 'pl-PL'));   // "5 plików"
console.log(formatFileCount(22, 'pl-PL'));  // "22 pliki"

各语言的代码结构保持一致,仅消息对象不同。这样分离后,译者可以为自己的语言提供正确的消息,而无需修改代码。

如何处理缺失的复数类别

你的消息对象可能不会包含全部六种类别。大多数语言只用两到三种。当 select() 返回的类别在消息对象中不存在时,请回退到 other 类别。

function formatFileCount(count, locale, messages) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category] || messages.other;
  return message.replace('{count}', count);
}

const englishMessages = {
  one: '{count} file',
  other: '{count} files'
};

console.log(formatFileCount(1, 'en-US', englishMessages));  // "1 file"
console.log(formatFileCount(5, 'en-US', englishMessages));  // "5 files"

这种模式确保即使消息对象不完整,代码也能正常工作。other 类别在每种语言中都存在,因此是安全的回退选项。

如何将复数规则用于序数

Intl.PluralRules 构造函数接受一个 type 选项,用于改变类别的判定方式。默认类型是 "cardinal",用于计数。设置 type: "ordinal" 可以确定像“第 1”、“第 2”、“第 3”这样的序数的复数形式。

const cardinalRules = new Intl.PluralRules('en-US', { type: 'cardinal' });
console.log(cardinalRules.select(1));  // "one"
console.log(cardinalRules.select(2));  // "other"
console.log(cardinalRules.select(3));  // "other"

const ordinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });
console.log(ordinalRules.select(1));   // "one"
console.log(ordinalRules.select(2));   // "two"
console.log(ordinalRules.select(3));   // "few"
console.log(ordinalRules.select(4));   // "other"

基数规则决定了“1 个项目、2 个项目”的表达方式。序数规则决定了“第 1 名、第 2 名、第 3 名、第 4 名”。返回的类别不同,是因为语法模式不同。

如何格式化小数数量

select() 方法支持小数。不同语言对于小数如何映射到复数类别有各自的规则。

const rules = new Intl.PluralRules('en-US');
console.log(rules.select(1));    // "one"
console.log(rules.select(1.0));  // "one"
console.log(rules.select(1.5));  // "other"
console.log(rules.select(0.5));  // "other"

在英文中,1.0 使用单数形式,但 1.5 使用复数形式。有些语言对小数有不同的规则,将其视为单独的类别。

const messages = {
  one: '{count} file',
  other: '{count} files'
};

const rules = new Intl.PluralRules('en-US');
const count = 1.5;
const category = rules.select(count);
const message = messages[category];
console.log(message.replace('{count}', count));  // "1.5 files"

直接将小数传递给 select()。该方法会自动应用正确的语言规则。

如何创建可复用的复数格式化器

无需在应用中重复相同的模式,可以创建一个可复用的函数,封装复数选择逻辑。

class PluralFormatter {
  constructor(locale) {
    this.locale = locale;
    this.rules = new Intl.PluralRules(locale);
  }

  format(count, messages) {
    const category = this.rules.select(count);
    const message = messages[category] || messages.other;
    return message.replace('{count}', count);
  }
}

const formatter = new PluralFormatter('en-US');

const fileMessages = {
  one: '{count} file',
  other: '{count} files'
};

const itemMessages = {
  one: '{count} item',
  other: '{count} items'
};

console.log(formatter.format(1, fileMessages));  // "1 file"
console.log(formatter.format(5, fileMessages));  // "5 files"
console.log(formatter.format(1, itemMessages));  // "1 item"
console.log(formatter.format(3, itemMessages));  // "3 items"

此类会创建 PluralRules 对象,并在多次格式化操作中复用。你可以扩展它,实现更高级的功能,比如在插入消息前用 Intl.NumberFormat 格式化计数。

如何将复数规则集成到翻译系统中

专业翻译系统会将消息与复数类别的占位符一起存储。翻译文本时,你需要提供目标语言所需的所有复数形式。

const translations = {
  'en-US': {
    fileCount: {
      one: '{count} file',
      other: '{count} files'
    },
    downloadComplete: {
      one: 'Download of {count} file complete',
      other: 'Download of {count} files complete'
    }
  },
  'pl-PL': {
    fileCount: {
      one: '{count} plik',
      few: '{count} pliki',
      many: '{count} plików'
    },
    downloadComplete: {
      one: 'Pobieranie {count} pliku zakończone',
      few: 'Pobieranie {count} plików zakończone',
      many: 'Pobieranie {count} plików zakończone'
    }
  }
};

class Translator {
  constructor(locale, translations) {
    this.locale = locale;
    this.translations = translations[locale] || {};
    this.rules = new Intl.PluralRules(locale);
  }

  translate(key, count) {
    const messages = this.translations[key];
    if (!messages) return key;

    const category = this.rules.select(count);
    const message = messages[category] || messages.other;
    return message.replace('{count}', count);
  }
}

const translator = new Translator('en-US', translations);
console.log(translator.translate('fileCount', 1));         // "1 file"
console.log(translator.translate('fileCount', 5));         // "5 files"
console.log(translator.translate('downloadComplete', 1));  // "Download of 1 file complete"
console.log(translator.translate('downloadComplete', 5));  // "Download of 5 files complete"

const polishTranslator = new Translator('pl-PL', translations);
console.log(polishTranslator.translate('fileCount', 1));   // "1 plik"
console.log(polishTranslator.translate('fileCount', 2));   // "2 pliki"
console.log(polishTranslator.translate('fileCount', 5));   // "5 plików"

这种模式将翻译数据与代码逻辑分离。译者为其语言使用的每个复数类别提供消息,你的代码会自动应用相应规则。

如何检查某个语言环境使用了哪些复数类别

resolvedOptions() 方法会返回 PluralRules 对象的信息,但不会列出该语言环境使用了哪些类别。要找出所有类别,可以测试一系列数字并收集返回的唯一类别。

function getPluralCategories(locale) {
  const rules = new Intl.PluralRules(locale);
  const categories = new Set();

  for (let i = 0; i <= 100; i++) {
    categories.add(rules.select(i));
    categories.add(rules.select(i + 0.5));
  }

  return Array.from(categories).sort();
}

console.log(getPluralCategories('en-US'));  // ["one", "other"]
console.log(getPluralCategories('pl-PL'));  // ["few", "many", "one"]
console.log(getPluralCategories('ar-EG'));  // ["few", "many", "one", "other", "two", "zero"]

此技术会在一定范围内测试整数和半数值,以便捕获消息对象在特定语言环境下需要包含的类别。