Как форматировать списки с «или» в JavaScript
Используйте Intl.ListFormat с типом disjunction для правильного форматирования альтернатив на любом языке
Введение
В приложениях часто нужно показывать пользователям выбор или альтернативы. Например, компонент загрузки файлов принимает файлы «PNG, JPEG или SVG». В форме оплаты можно выбрать «кредитную карту, дебетовую карту или PayPal» как способ оплаты. Сообщение об ошибке предлагает исправить «имя пользователя, пароль или адрес электронной почты», чтобы устранить проблемы с авторизацией.
В этих списках используется «или» для обозначения альтернатив. Если форматировать такие списки вручную с помощью конкатенации строк, это не будет работать на других языках, потому что в разных языках разные правила пунктуации, разные слова для «или» и разные правила расстановки запятых. API Intl.ListFormat с типом disjunction позволяет корректно форматировать такие альтернативные списки на любом языке.
Что такое дизъюнктивные списки
Дизъюнктивный список — это список альтернатив, из которых обычно выбирается только один вариант. Слово «дизъюнкция» означает разделение или альтернативу. В английском языке в таких списках используется союз «or»:
const paymentMethods = ["credit card", "debit card", "PayPal"];
// Desired output: "credit card, debit card, or PayPal"
Это отличается от конъюнктивных списков, где используется «and» и подразумевается, что все элементы применяются вместе. Дизъюнктивные списки сообщают о выборе, а конъюнктивные — о сочетании.
Типичные примеры дизъюнктивных списков — варианты оплаты, ограничения по форматам файлов, рекомендации по устранению неполадок, альтернативы фильтров поиска и любые интерфейсы, где пользователь выбирает один вариант из нескольких.
Почему ручное форматирование не работает
В английском языке дизъюнктивные списки пишутся как «A, B или C» — с запятыми между элементами и «или» перед последним. Такая схема не работает на других языках:
// Hardcoded English pattern
const items = ["apple", "orange", "banana"];
const text = items.slice(0, -1).join(", ") + ", or " + items[items.length - 1];
// "apple, orange, or banana"
Этот код выдаёт некорректный результат на испанском, французском, немецком и большинстве других языков. В каждом языке свои правила оформления дизъюнктивных списков.
В испанском используется «o» без запятой перед ним:
Expected: "manzana, naranja o plátano"
English pattern produces: "manzana, naranja, or plátano"
Во французском используется «ou» без запятой перед ним:
Expected: "pomme, orange ou banane"
English pattern produces: "pomme, orange, or banane"
В немецком используется «oder» без запятой перед ним:
Expected: "Apfel, Orange oder Banane"
English pattern produces: "Apfel, Orange, or Banane"
В японском используется частица «か» (ka) с другой пунктуацией:
Expected: "りんご、オレンジ、またはバナナ"
English pattern produces: "りんご、オレンジ、 or バナナ"
Эти различия выходят за рамки простого замещения слов. Расстановка знаков препинания, правила пробелов и грамматические частицы различаются в каждом языке. Ручная конкатенация строк не справится с такой сложностью.
Использование Intl.ListFormat с типом disjunction
API Intl.ListFormat форматирует списки по языковым правилам. Установите опцию type в значение "disjunction", чтобы форматировать альтернативные списки:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
const paymentMethods = ["credit card", "debit card", "PayPal"];
console.log(formatter.format(paymentMethods));
// "credit card, debit card, or PayPal"
Форматтер работает с любым количеством элементов в массиве:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
console.log(formatter.format([]));
// ""
console.log(formatter.format(["credit card"]));
// "credit card"
console.log(formatter.format(["credit card", "PayPal"]));
// "credit card or PayPal"
console.log(formatter.format(["credit card", "debit card", "PayPal"]));
// "credit card, debit card, or PayPal"
API автоматически применяет правильную пунктуацию и союз для каждого случая.
Как устроены стили дизъюнкции
Опция style управляет степенью подробности форматирования. Существует три стиля: long, short и narrow. Стиль long используется по умолчанию.
const items = ["email", "phone", "SMS"];
const long = new Intl.ListFormat("en", {
type: "disjunction",
style: "long"
});
console.log(long.format(items));
// "email, phone, or SMS"
const short = new Intl.ListFormat("en", {
type: "disjunction",
style: "short"
});
console.log(short.format(items));
// "email, phone, or SMS"
const narrow = new Intl.ListFormat("en", {
type: "disjunction",
style: "narrow"
});
console.log(narrow.format(items));
// "email, phone, or SMS"
В английском все три стиля дают одинаковый результат для дизъюнктивных списков. В других языках различия заметнее. В немецком используется «oder» в длинном стиле и возможны сокращения в узком стиле. Различия особенно заметны в языках с несколькими уровнями формальности или длинными союзами.
Узкий стиль обычно убирает пробелы или использует короткие союзы для экономии места в ограниченных по размеру интерфейсах. Используйте длинный стиль для обычного текста, короткий — для более компактных отображений, а узкий — для очень ограниченных пространств, например, на мобильных или в компактных таблицах.
Как дизъюнктивные списки выглядят на разных языках
В каждом языке раздельные списки оформляются по своим правилам. Intl.ListFormat автоматически учитывает эти различия.
В английском используются запятые и «or»:
const en = new Intl.ListFormat("en", { type: "disjunction" });
console.log(en.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG, or SVG"
В испанском используются запятые и «o», без запятой перед последним союзом:
const es = new Intl.ListFormat("es", { type: "disjunction" });
console.log(es.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG o SVG"
Во французском используются запятые и «ou», без запятой перед последним союзом:
const fr = new Intl.ListFormat("fr", { type: "disjunction" });
console.log(fr.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG ou SVG"
В немецком используются запятые и «oder», без запятой перед последним союзом:
const de = new Intl.ListFormat("de", { type: "disjunction" });
console.log(de.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG oder SVG"
В японском используются другие знаки препинания и частицы:
const ja = new Intl.ListFormat("ja", { type: "disjunction" });
console.log(ja.format(["PNG", "JPEG", "SVG"]));
// "PNG、JPEG、またはSVG"
В китайском используются китайские знаки препинания:
const zh = new Intl.ListFormat("zh", { type: "disjunction" });
console.log(zh.format(["PNG", "JPEG", "SVG"]));
// "PNG、JPEG或SVG"
Эти примеры показывают, как API адаптируется к грамматике и пунктуации каждого языка. Один и тот же код работает для всех языков, если указать нужную локаль.
Форматирование вариантов оплаты
В формах оплаты часто есть несколько способов оплаты. Их оформляют раздельными списками:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function getPaymentMessage(methods) {
if (methods.length === 0) {
return "No payment methods available";
}
return `Pay with ${formatter.format(methods)}.`;
}
const methods = ["credit card", "debit card", "PayPal", "Apple Pay"];
console.log(getPaymentMessage(methods));
// "Pay with credit card, debit card, PayPal, or Apple Pay."
Для международных приложений передавайте локаль пользователя:
const userLocale = navigator.language; // e.g., "fr-FR"
const formatter = new Intl.ListFormat(userLocale, { type: "disjunction" });
function getPaymentMessage(methods) {
if (methods.length === 0) {
return "No payment methods available";
}
return `Pay with ${formatter.format(methods)}.`;
}
Этот подход работает в процессах оформления заказа, выборе способа оплаты и в любом интерфейсе, где пользователь выбирает, как платить.
Форматирование ограничений на загрузку файлов
В компонентах загрузки файлов указываются поддерживаемые типы файлов:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function getAcceptedFormatsMessage(formats) {
if (formats.length === 0) {
return "No file formats accepted";
}
if (formats.length === 1) {
return `Accepted format: ${formats[0]}`;
}
return `Accepted formats: ${formatter.format(formats)}`;
}
const imageFormats = ["PNG", "JPEG", "SVG", "WebP"];
console.log(getAcceptedFormatsMessage(imageFormats));
// "Accepted formats: PNG, JPEG, SVG, or WebP"
const documentFormats = ["PDF", "DOCX"];
console.log(getAcceptedFormatsMessage(documentFormats));
// "Accepted formats: PDF or DOCX"
Этот шаблон подходит для загрузки изображений, отправки документов и любого ввода файлов с ограничениями по формату.
Форматирование советов по устранению неполадок
Сообщения об ошибках часто предлагают несколько способов решения проблемы. Представляйте эти предложения в виде дизъюнктивных списков:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function getAuthenticationError(missingFields) {
if (missingFields.length === 0) {
return "Authentication failed";
}
return `Please check your ${formatter.format(missingFields)} and try again.`;
}
console.log(getAuthenticationError(["username", "password"]));
// "Please check your username or password and try again."
console.log(getAuthenticationError(["email", "username", "password"]));
// "Please check your email, username, or password and try again."
Дизъюнктивный список показывает, что пользователю нужно исправить любое из указанных полей, а не обязательно все сразу.
Форматирование альтернатив фильтров поиска
В поисковых интерфейсах отображаются активные фильтры. Если фильтры содержат альтернативы, используйте дизъюнктивные списки:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function getFilterSummary(filters) {
if (filters.length === 0) {
return "No filters applied";
}
if (filters.length === 1) {
return `Showing results for: ${filters[0]}`;
}
return `Showing results for: ${formatter.format(filters)}`;
}
const categories = ["Electronics", "Books", "Clothing"];
console.log(getFilterSummary(categories));
// "Showing results for: Electronics, Books, or Clothing"
Это подходит для фильтров по категориям, тегам и любых интерфейсов фильтрации, где выбранные значения представляют альтернативы, а не комбинации.
Повторное использование форматтеров для производительности
Создание экземпляров Intl.ListFormat требует ресурсов. Создавайте форматтеры один раз и используйте повторно:
// Create once at module level
const disjunctionFormatter = new Intl.ListFormat("en", { type: "disjunction" });
// Reuse in multiple functions
function formatPaymentMethods(methods) {
return disjunctionFormatter.format(methods);
}
function formatFileTypes(types) {
return disjunctionFormatter.format(types);
}
function formatErrorSuggestions(suggestions) {
return disjunctionFormatter.format(suggestions);
}
Для приложений с поддержкой нескольких локалей храните форматтеры в кэше:
const formatters = new Map();
function getDisjunctionFormatter(locale) {
if (!formatters.has(locale)) {
formatters.set(
locale,
new Intl.ListFormat(locale, { type: "disjunction" })
);
}
return formatters.get(locale);
}
const formatter = getDisjunctionFormatter("en");
console.log(formatter.format(["A", "B", "C"]));
// "A, B, or C"
Этот подход снижает затраты на инициализацию и поддерживает работу с несколькими локалями во всём приложении.
Использование formatToParts для кастомного рендеринга
Метод formatToParts() возвращает массив объектов, представляющих каждую часть отформатированного списка. Это позволяет применять собственные стили:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
const parts = formatter.formatToParts(["PNG", "JPEG", "SVG"]);
console.log(parts);
// [
// { type: "element", value: "PNG" },
// { type: "literal", value: ", " },
// { type: "element", value: "JPEG" },
// { type: "literal", value: ", or " },
// { type: "element", value: "SVG" }
// ]
Каждая часть содержит type и value. type — это либо "element" для элементов списка, либо "literal" для знаков препинания и союзов.
Используйте это, чтобы применять разные стили к элементам и литералам:
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
const formats = ["PNG", "JPEG", "SVG"];
const html = formatter.formatToParts(formats)
.map(part => {
if (part.type === "element") {
return `<code>${part.value}</code>`;
}
return part.value;
})
.join("");
console.log(html);
// "<code>PNG</code>, <code>JPEG</code>, or <code>SVG</code>"
Такой подход сохраняет корректную для локали пунктуацию и союзы, при этом позволяет кастомизировать отображение самих элементов.
Поддержка браузеров и совместимость
Intl.ListFormat работает во всех современных браузерах с апреля 2021 года. Поддерживаются Chrome 72+, Firefox 78+, Safari 14.1+ и Edge 79+.
Проверьте поддержку перед использованием API:
if (typeof Intl.ListFormat !== "undefined") {
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
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");
}
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
С учётом текущей поддержки браузеров, большинство приложений могут использовать Intl.ListFormat напрямую, без полифиллов.
Распространённые ошибки, которых стоит избегать
Использование типа conjunction вместо disjunction приводит к неправильному смыслу:
// Wrong: suggests all methods required
const wrong = new Intl.ListFormat("en", { type: "conjunction" });
console.log(`Pay with ${wrong.format(["credit card", "debit card"])}`);
// "Pay with credit card and debit card"
// Correct: suggests choosing one method
const correct = new Intl.ListFormat("en", { type: "disjunction" });
console.log(`Pay with ${correct.format(["credit card", "debit card"])}`);
// "Pay with credit card or debit card"
Постоянное создание новых форматтеров тратит ресурсы впустую:
// Inefficient
function formatOptions(options) {
return new Intl.ListFormat("en", { type: "disjunction" }).format(options);
}
// Efficient
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function formatOptions(options) {
return formatter.format(options);
}
Жёстко заданное "или" в строках мешает локализации:
// Breaks in other languages
const text = items.join(", ") + ", or other options";
// Works across languages
const formatter = new Intl.ListFormat(userLocale, { type: "disjunction" });
const allItems = [...items, "other options"];
const text = formatter.format(allItems);
Если не обрабатывать пустые массивы, можно получить неожиданный результат:
// Defensive
function formatPaymentMethods(methods) {
if (methods.length === 0) {
return "No payment methods available";
}
return formatter.format(methods);
}
Хотя format([]) возвращает пустую строку, явная обработка пустого состояния делает опыт пользователя лучше.
Когда использовать дизъюнктивные списки
Используйте дизъюнктивные списки, когда показываете альтернативы или варианты, из которых обычно выбирают один. Это, например, выбор способа оплаты, ограничения по формату файлов, предложения по ошибкам аутентификации, фильтры поиска и типы аккаунтов.
Не используйте дизъюнктивные списки, если все элементы должны применяться вместе. В таких случаях используйте конъюнктивные списки. Например, "Имя, email и пароль обязательны" — это конъюнкция, потому что нужны все поля, а не только одно.
Не используйте дизъюнктивные списки для нейтральных перечислений без выбора. Измерения и технические характеристики обычно оформляются как unit-списки, а не дизъюнкция или конъюнкция.
API заменяет ручные шаблоны склеивания строк для альтернатив. Каждый раз, когда вы пишете код, который объединяет элементы с помощью "или" для пользовательского текста, подумайте, не лучше ли использовать Intl.ListFormat с типом disjunction для лучшей поддержки локалей.