API Intl.PluralRules

Как правильно обрабатывать формы множественного числа в JavaScript

Введение

Плюрализация — это процесс отображения разного текста в зависимости от количества. Например, на английском показывают «1 item» для одного элемента и «2 items» для нескольких. Обычно разработчики просто добавляют «s» к слову, если количество не равно одному.

Такой подход не работает для других языков. В польском используются разные формы для 1, 2–4 и 5 и более. В арабском есть формы для нуля, одного, двух, нескольких и многих. В валлийском — шесть разных форм. Даже в английском есть исключения, например, «person» и «people», которые требуют отдельной обработки.

API Intl.PluralRules решает эту задачу, предоставляя категорию формы множественного числа для любого числа на любом языке. Вы передаёте количество, а API сообщает, какую форму использовать по правилам нужного языка. Это позволяет писать код, готовый к интернационализации, который корректно работает для разных языков без ручного прописывания языковых правил.

Как языки обрабатывают формы множественного числа

Языки сильно различаются в выражении количества. В английском две формы: единственное число для одного, множественное — для всего остального. Это кажется простым, пока не столкнёшься с языками с другими системами.

В русском и польском три формы. Единственное число используется для одного предмета. Особая форма — для чисел, оканчивающихся на 2, 3 или 4 (но не 12, 13, 14). Для всех остальных чисел используется третья форма.

В арабском шесть форм: ноль, один, два, несколько (3–10), много (11–99) и прочие (100+). В валлийском тоже шесть форм, но с другими числовыми границами.

В некоторых языках, например, китайском и японском, вообще нет различия между единственным и множественным числом. Одна и та же форма используется для любого количества.

API Intl.PluralRules абстрагирует эти различия, используя стандартные имена категорий на основе правил множественного числа Unicode CLDR. Всего есть шесть категорий: zero, one, two, few, many и other. Не все языки используют все шесть категорий. В английском используются только one и other. В арабском — все шесть.

Создание экземпляра PluralRules для локали

Конструктор Intl.PluralRules принимает идентификатор локали и возвращает объект, который может определить, к какой категории множественного числа относится заданное число.

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

Создавайте по одному экземпляру на каждую локаль и переиспользуйте его. Создавать новый экземпляр для каждой операции — неэффективно. Сохраняйте экземпляр в переменной или используйте кэширование.

Тип по умолчанию — cardinal, он используется для подсчёта объектов. Также можно создать правила для порядковых числительных, передав объект с опциями.

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}`;
}

Всегда добавляйте элементы для всех категорий, которые используются в языке. Если пропустить категорию, поиск по ключу вернёт undefined. Если не уверены, какие категории есть в языке, проверьте правила множественного числа 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'

В других языках правила для десятичных чисел отличаются. В некоторых любое десятичное число считается множественным, в других — используются более сложные правила, зависящие от дробной части.

Если в интерфейсе отображаются дробные значения, например, «1,5 ГБ» или «2,7 мили», передавайте дробное число напрямую в select(). Не округляйте заранее, если только интерфейс не округляет отображаемое значение.

Форматируйте порядковые номера: 1-й, 2-й, 3-й

Порядковые номера показывают позицию или ранг. В английском языке к числам добавляются суффиксы: 1st, 2nd, 3rd, 4th. Это не просто «добавить th», потому что 1, 2 и 3 имеют особые формы, а числа, заканчивающиеся на 1, 2 или 3, следуют особым правилам (21st, 22nd, 23rd), кроме случаев, когда они заканчиваются на 11, 12 или 13 (11th, 12th, 13th).

API Intl.PluralRules обрабатывает эти правила, когда вы указываете type: 'ordinal'.

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'

Используйте 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». В некоторых языках для нуля используется единственное число, а в других — отдельная категория для нуля.

API Intl.PluralRules возвращает подходящую категорию для нуля в зависимости от языковых правил. В английском для нуля возвращается 'other', что соответствует множественной форме:

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

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

В арабском языке для нуля есть отдельная категория:

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

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

Ваш текст должен это учитывать. В английском, например, лучше показывать «Нет элементов» вместо «0 элементов» для лучшего UX. Обрабатывайте это до вызова правил множественного числа:

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 включает разбор локали и загрузку данных о правилах множественного числа. Делайте это один раз для каждой локали, а не при каждом вызове функции или рендере.

// 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}`;
}

Если вы поддерживаете несколько локалей, создайте экземпляры для каждой и храните их в 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');

Такой паттерн распределяет стоимость инициализации между многими вызовами.

Поддержка браузеров и совместимость

Intl.PluralRules поддерживается во всех современных браузерах с 2019 года. Включая Chrome 63+, Firefox 58+, Safari 13+ и Edge 79+. В Internet Explorer не поддерживается.

Для приложений, ориентированных на современные браузеры, можно использовать Intl.PluralRules без polyfill. Если нужно поддерживать старые браузеры, polyfill доступны через пакеты вроде intl-pluralrules на npm.

Метод selectRange() новее и поддерживается чуть менее широко. Он доступен в Chrome 106+, Firefox 116+, Safari 15.4+ и Edge 106+. Проверьте совместимость, если используете selectRange() и поддерживаете старые версии браузеров.

Не хардкодьте формы множественного числа в логике

Не проверяйте количество и не выбирайте форму во внутренней логике. Такой подход не масштабируется для языков с более чем двумя формами и привязывает вашу логику к английским правилам.

// 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}`;
}

Этот подход работает одинаково для любого языка. Меняются только экземпляр правил и Map с формами.

Тестируйте с разными локалями и граничными случаями

В правилах множественного числа есть граничные случаи, которые легко упустить, если тестировать только на английском. Проверьте свою логику склонения хотя бы на одном языке с более чем двумя формами, например, на польском или арабском.

Проверьте числа, которые попадают в разные категории:

  • Ноль
  • Один
  • Два
  • Несколько (3–10 в арабском)
  • Много (11–99 в арабском)
  • Большие числа (100+)
  • Дробные значения (0,5; 1,5; 2,3)
  • Отрицательные числа, если ваш интерфейс их показывает

Если вы используете порядковые правила, проверьте числа, которые вызывают разные суффиксы: 1, 2, 3, 4, 11, 21, 22, 23. Это поможет корректно обработать особые случаи.

Раннее тестирование с разными локалями помогает избежать сюрпризов при добавлении новых языков. Это также подтверждает, что ваша структура данных включает все нужные категории, а логика их правильно обрабатывает.