Intl.ListFormat API

Форматируйте массивы в читаемые списки с учётом локали

Введение

Когда нужно показать пользователям несколько элементов, разработчики часто объединяют массивы через запятую и добавляют «и» перед последним элементом:

const users = ["Alice", "Bob", "Charlie"];
const message = users.slice(0, -1).join(", ") + ", and " + users[users.length - 1];
// "Alice, Bob, and Charlie"

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

Что делает Intl.ListFormat

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

  • Союзные списки используют «и» для соединения элементов («A, B и C»)
  • Разделительные списки используют «или» для выбора («A, B или C»)
  • Списки единиц форматируют измерения без союзов («5 футов, 2 дюйма»)

API знает, как каждый язык форматирует эти типы списков — от пунктуации до выбора слов и пробелов.

Базовое использование

Создайте форматтер с нужной локалью и опциями, затем вызовите format() с массивом:

const formatter = new Intl.ListFormat("en", {
  type: "conjunction",
  style: "long"
});

const items = ["bread", "milk", "eggs"];
console.log(formatter.format(items));
// "bread, milk, and eggs"

Форматтер работает с массивами любой длины, включая крайние случаи:

formatter.format([]);              // ""
formatter.format(["bread"]);       // "bread"
formatter.format(["bread", "milk"]); // "bread and milk"

Типы списков управляют союзами

Опция type определяет, какой союз будет использоваться в отформатированном списке.

Союзные списки

Используйте type: "conjunction" для списков, где все элементы применяются вместе. Это тип по умолчанию:

const formatter = new Intl.ListFormat("en", { type: "conjunction" });

console.log(formatter.format(["HTML", "CSS", "JavaScript"]));
// "HTML, CSS, and JavaScript"

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

Разделительные списки

Используйте type: "disjunction" для списков, где представлены альтернативы или варианты:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

console.log(formatter.format(["credit card", "debit card", "PayPal"]));
// "credit card, debit card, or PayPal"

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

Списки единиц измерения

Используйте type: "unit" для измерений и технических значений, которые должны отображаться без союзов:

const formatter = new Intl.ListFormat("en", { type: "unit" });

console.log(formatter.format(["5 feet", "2 inches"]));
// "5 feet, 2 inches"

Списки единиц подходят для измерений, технических характеристик и составных значений.

Стили списков управляют подробностью

Опция style регулирует, насколько подробно будет выглядеть форматирование. Существует три стиля: long, short и narrow.

const items = ["Monday", "Wednesday", "Friday"];

const long = new Intl.ListFormat("en", { style: "long" });
console.log(long.format(items));
// "Monday, Wednesday, and Friday"

const short = new Intl.ListFormat("en", { style: "short" });
console.log(short.format(items));
// "Monday, Wednesday, and Friday"

const narrow = new Intl.ListFormat("en", { style: "narrow" });
console.log(narrow.format(items));
// "Monday, Wednesday, Friday"

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

Как разные языки форматируют списки

У каждого языка свои правила оформления списков. Intl.ListFormat автоматически учитывает эти различия.

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

const en = new Intl.ListFormat("en");
console.log(en.format(["Tokyo", "Paris", "London"]));
// "Tokyo, Paris, and London"

В немецком структура запятых такая же, но союзы другие:

const de = new Intl.ListFormat("de");
console.log(de.format(["Tokyo", "Paris", "London"]));
// "Tokyo, Paris und London"

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

const ja = new Intl.ListFormat("ja");
console.log(ja.format(["東京", "パリ", "ロンドン"]));
// "東京、パリ、ロンドン"

В китайском вообще другая пунктуация:

const zh = new Intl.ListFormat("zh");
console.log(zh.format(["东京", "巴黎", "伦敦"]));
// "东京、巴黎和伦敦"

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

Использование formatToParts для кастомного рендера

Метод formatToParts() возвращает массив объектов вместо строки. Каждый объект — это отдельная часть отформатированного списка:

const formatter = new Intl.ListFormat("en");
const parts = formatter.formatToParts(["red", "green", "blue"]);

console.log(parts);
// [
//   { type: "element", value: "red" },
//   { type: "literal", value: ", " },
//   { type: "element", value: "green" },
//   { type: "literal", value: ", and " },
//   { type: "element", value: "blue" }
// ]

У каждой части есть type и value. type — это либо "element" для элементов списка, либо "literal" для пунктуации и союзов.

Эта структура позволяет кастомно отображать элементы и литералы с разным стилем:

const formatter = new Intl.ListFormat("en");
const items = ["Alice", "Bob", "Charlie"];

const html = formatter.formatToParts(items)
  .map(part => {
    if (part.type === "element") {
      return `<strong>${part.value}</strong>`;
    }
    return part.value;
  })
  .join("");

console.log(html);
// "<strong>Alice</strong>, <strong>Bob</strong>, and <strong>Charlie</strong>"

Такой подход сохраняет пунктуацию, соответствующую локали, и при этом позволяет индивидуально оформить сами элементы списка.

Повторное использование форматтеров для производительности

Создание экземпляров Intl.ListFormat требует ресурсов. Создайте форматтеры один раз и переиспользуйте их:

// Create once
const listFormatter = new Intl.ListFormat("en", { type: "conjunction" });

// Reuse many times
function displayUsers(users) {
  return listFormatter.format(users.map(u => u.name));
}

function displayTags(tags) {
  return listFormatter.format(tags);
}

Для приложений с несколькими локалями храните форматтеры в map:

const formatters = new Map();

function getListFormatter(locale, options) {
  const key = `${locale}-${options.type}-${options.style}`;
  if (!formatters.has(key)) {
    formatters.set(key, new Intl.ListFormat(locale, options));
  }
  return formatters.get(key);
}

const formatter = getListFormatter("en", { type: "conjunction", style: "long" });
console.log(formatter.format(["a", "b", "c"]));

Этот подход снижает издержки на повторную инициализацию и поддерживает работу с разными локалями и настройками.

Форматирование сообщений об ошибках

Валидация форм часто возвращает несколько ошибок. Форматируйте их с помощью дизъюнктивных списков, чтобы показать варианты:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push("at least 8 characters");
  }
  if (!/[A-Z]/.test(password)) {
    errors.push("an uppercase letter");
  }
  if (!/[0-9]/.test(password)) {
    errors.push("a number");
  }

  if (errors.length > 0) {
    return `Password must contain ${formatter.format(errors)}.`;
  }

  return null;
}

console.log(validatePassword("weak"));
// "Password must contain at least 8 characters, an uppercase letter, or a number."

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

Отображение выбранных элементов

Когда пользователь выбирает несколько элементов, оформляйте выбор с помощью конъюнктивных списков:

const formatter = new Intl.ListFormat("en", { type: "conjunction" });

function getSelectionMessage(selectedFiles) {
  if (selectedFiles.length === 0) {
    return "No files selected";
  }

  if (selectedFiles.length === 1) {
    return `${selectedFiles[0]} selected`;
  }

  return `${formatter.format(selectedFiles)} selected`;
}

console.log(getSelectionMessage(["report.pdf", "data.csv", "notes.txt"]));
// "report.pdf, data.csv, and notes.txt selected"

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

Работа с длинными списками

Если список слишком длинный, подумайте о сокращении его перед форматированием:

const formatter = new Intl.ListFormat("en", { type: "conjunction" });

function formatUserList(users) {
  if (users.length <= 3) {
    return formatter.format(users);
  }

  const visible = users.slice(0, 2);
  const remaining = users.length - 2;

  return `${formatter.format(visible)}, and ${remaining} others`;
}

console.log(formatUserList(["Alice", "Bob", "Charlie", "David", "Eve"]));
// "Alice, Bob, and 3 others"

Так сохраняется читаемость и при этом видно общее количество. Конкретный порог зависит от ограничений интерфейса.

Поддержка браузеров и запасные варианты

Intl.ListFormat работает во всех современных браузерах с апреля 2021 года. Поддерживаются Chrome 72+, Firefox 78+, Safari 14.1+ и Edge 79+.

Проверьте поддержку с помощью feature detection:

if (typeof Intl.ListFormat !== "undefined") {
  const formatter = new Intl.ListFormat("en");
  return formatter.format(items);
} else {
  // Fallback for older browsers
  return items.join(", ");
}

Для большей совместимости используйте полифил, например @formatjs/intl-listformat. Устанавливайте его только в тех окружениях, где это действительно нужно:

if (typeof Intl.ListFormat === "undefined") {
  await import("@formatjs/intl-listformat/polyfill");
}

С учётом текущей поддержки браузеров большинство приложений могут использовать Intl.ListFormat напрямую, без полифилов.

Частые ошибки, которых стоит избегать

Постоянное создание новых форматтеров расходует ресурсы впустую:

// Inefficient
function display(items) {
  return new Intl.ListFormat("en").format(items);
}

// Efficient
const formatter = new Intl.ListFormat("en");
function display(items) {
  return formatter.format(items);
}

Использование array.join() для пользовательского текста создаёт проблемы с локализацией:

// Breaks in other languages
const text = items.join(", ");

// Works across languages
const formatter = new Intl.ListFormat(userLocale);
const text = formatter.format(items);

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

Если не обрабатывать пустые массивы, это может привести к неожиданному выводу:

// Defensive
function formatItems(items) {
  if (items.length === 0) {
    return "No items";
  }
  return formatter.format(items);
}

Хотя format([]) возвращает пустую строку, явная обработка пустого состояния улучшает пользовательский опыт.

Когда использовать Intl.ListFormat

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

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

Этот API заменяет ручное склеивание строк и шаблоны объединения. Каждый раз, когда вы пишете join(", ") для пользовательского текста, подумайте, не обеспечит ли Intl.ListFormat лучшую поддержку локалей.