Перейти к содержанию

Множественные формы через Intl.PluralRules

Словарь «Параллели» уже хранит целые сообщения и умеет подставлять название статьи. Теперь покажем число материалов в разделе. Русская фраза меняется: «1 урок», «2 урока», «5 уроков». Английская версия требует другую систему форм. Условие count === 1 не решает русскую задачу, а проверка последней цифры ломается на числах вроде 11 и 12.

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

Категория и готовая фраза

Для русских целых количеств нас интересуют one, few, many. Категория other нужна для иных значений, в частности некоторых дробных представлений. Названия категорий служат ключами API; они не являются словами, которые выводятся читателю. Для английского сообщения будем использовать one и other.

В web/messages.mjs рядом с существующим словарём добавим следующие определения. Это полный новый блок; прежняя функция message остаётся без изменений:

const lessonMessages = {
  ru: {
    one: "{count} урок",
    few: "{count} урока",
    many: "{count} уроков",
    other: "{count} урока"
  },
  en: {
    one: "{count} lesson",
    other: "{count} lessons"
  }
};

const locales = { ru: "ru-RU", en: "en-GB" };

export function lessonCount(language, count) {
  if (!Number.isSafeInteger(count) || count < 0) {
    throw new RangeError("Count must be a non-negative safe integer");
  }
  const locale = locales[language];
  if (!locale) throw new Error("Unsupported language");
  const category = new Intl.PluralRules(locale).select(count);
  const forms = lessonMessages[language];
  const template = forms[category] ?? forms.other;
  const formatted = new Intl.NumberFormat(locale).format(count);
  return template.replace("{count}", formatted);
}

Количество уроков по договору является неотрицательным целым числом. Поэтому дробь 1.5 будет отклонена до выбора формы. Это не ограничение Intl: сама технология умеет работать с дробными значениями. Мы ограничили предметную область, чтобы не выдавать посетителю полтора опубликованных урока из-за ошибки источника данных.

Форматирование числа выполняет отдельный Intl.NumberFormat. При больших значениях оно может добавить локализованные разделители разрядов. Поэтому фраза не предполагает, что отображаемое число совпадает с простой строкой String(count). Хранимым значением остаётся число, а представление выбирается непосредственно перед выводом.

Наблюдаемые варианты

Для русского словаря ожидаем следующую таблицу. Она описывает договор примера, а не результаты запущенного при подготовке сценария:

Количество Фраза
0 0 уроков
1 1 урок
2 2 урока
5 5 уроков
11 11 уроков
21 21 урок
22 22 урока
25 25 уроков

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

Для ручного рассмотрения читатель может вызвать функцию на знакомых числах:

for (const count of [0, 1, 2, 5, 11, 21, 22, 25]) {
  console.log(count, lessonCount("ru", count), lessonCount("en", count));
}

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

Почему переводим предложение целиком

Рассмотрим более сложное сообщение: «Сохранён 1 урок» и «Сохранено 5 уроков». Меняется уже не только существительное. Если словарь хранит отдельные части «Сохранён», число и название объекта, грамматическая согласованность теряется. Для такого сообщения заведите собственные варианты всей фразы, а не применяйте функцию lessonCount как универсальный переводчик.

Это относится и к другим языкам: категория количества может влиять на место слова, артикль или глагол. Наш пример выбирает одну подпись каталога и не претендует на полноценный синтаксис ICU MessageFormat. Когда сообщений с несколькими числовыми условиями станет много, готовая библиотека с формальными правилами будет удобнее самодельной вложенной замены.

Из документации Intl.PluralRules важно взять точный смысл возвращаемой категории. Писать собственный набор языковых правил для каждого поддерживаемого языка нам не требуется. Однако сам словарь, смысл числа и политика ошибок остаются обязанностью приложения.

Ноль, отсутствующее число и загрузка

Нулевое количество отличается от неизвестного. Если данные каталога ещё загружаются, нельзя подставлять undefined, превращать его в ноль и сообщать «0 уроков». Читатель увидит отсутствие материалов, хотя сервер ещё не ответил. Для загрузки и ошибки предусмотрим отдельные сообщения без числовой формы.

То же относится к пропущенному полю в JSON. Проверка Number.isSafeInteger заставит вызывающий код решить, что делать с неправильными данными. В интерфейсе можно показать «Не удалось получить количество» и оставить сами ссылки доступными. Такая ошибка не должна автоматически скрывать весь раздел или создавать ложное сообщение о подготовке материалов.

Количество относится к определённой выборке

Число в подписи должно описывать именно тот список, который видит читатель. У «Параллели» два русских материала и один английский. Если английский каталог показывает «2 lessons», используя общее число ID, посетитель ожидает две доступные английские статьи и не находит одну. Ошибка находится в выборе данных, хотя функция множественной формы работает правильно.

Поэтому перед форматированием собираем версии, опубликованные для текущего языка, и считаем их длину. При пагинации различаем общее число результатов и число карточек на текущей странице. Подпись «20 уроков» может обозначать весь раздел, а «Показаны 1–10 из 20» — диапазон. Каждое сообщение получает собственный смысл, параметры и перевод целиком.

Если фильтр применён в браузере, количество пересчитывается из отфильтрованной выборки. Не уменьшайте его на число скрытых DOM-элементов, среди которых могут быть шаблонные заготовки. Данные и их представление должны иметь общий источник. После сброса фильтра исходный список восстановится, а функция lessonCount получит новое правильное число. Так языковые правила не маскируют ошибки каталога и не создают ложное впечатление отсутствующих материалов.

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

Оглавление серии