Как сортировать строки по алфавиту с учётом локали в JavaScript

Используйте Intl.Collator и localeCompare() для правильной сортировки строк на любом языке

Введение

Когда вы сортируете массив строк в JavaScript, по умолчанию строки сравниваются по значениям их кодовых единиц UTF-16. Это работает для простого ASCII-текста, но не подходит для сортировки имён, названий товаров или любого текста с акцентированными символами, нелатинскими алфавитами или смешанным регистром.

В разных языках свои правила алфавитного порядка. В шведском буквы å, ä и ö идут в конце алфавита после z. В немецком ä обычно считается эквивалентом a. Во французском в некоторых режимах сравнения игнорируются акценты. Эти языковые правила определяют, как пользователи ожидают видеть отсортированные списки на своём языке.

В JavaScript есть два API для сортировки строк с учётом локали. Метод String.prototype.localeCompare() подходит для простых сравнений. API Intl.Collator обеспечивает лучшую производительность при сортировке больших массивов. В этом уроке объясняется, как работают оба подхода, когда какой использовать и как настраивать сортировку для разных языков.

Почему стандартная сортировка не подходит для интернационального текста

Стандартный метод Array.sort() сравнивает строки по значениям их кодовых единиц UTF-16. Это значит, что заглавные буквы всегда идут перед строчными, а символы с акцентами сортируются после z.

const names = ['Åsa', 'Anna', 'Örjan', 'Bengt', 'Ärla'];
const sorted = names.sort();
console.log(sorted);
// Output: ['Anna', 'Bengt', 'Åsa', 'Ärla', 'Örjan']

Этот результат неверен для шведского языка. В шведском å, ä и ö — отдельные буквы, которые должны быть в конце алфавита. Правильный порядок: сначала Anna, потом Bengt, затем Åsa, Ärla и Örjan.

Проблема возникает потому, что стандартная сортировка сравнивает значения кодовых точек, а не лингвистический смысл. Буква Å имеет кодовую точку U+00C5, которая больше, чем у z (U+007A). JavaScript не знает, что для шведов Å — отдельная буква с определённым местом в алфавите.

Смешанный регистр создаёт ещё одну проблему.

const words = ['zebra', 'Apple', 'banana', 'Zoo'];
const sorted = words.sort();
console.log(sorted);
// Output: ['Apple', 'Zoo', 'banana', 'zebra']

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

Как localeCompare сортирует строки по языковым правилам

Метод localeCompare() сравнивает две строки согласно правилам сортировки конкретной локали. Он возвращает отрицательное число, если первая строка идёт раньше второй, ноль — если они равны, и положительное число, если первая строка идёт после второй.

const result = 'a'.localeCompare('b', 'en-US');
console.log(result);
// Output: -1 (negative means 'a' comes before 'b')

Вы можете использовать localeCompare() напрямую с Array.sort(), передав его как функцию сравнения.

const names = ['Åsa', 'Anna', 'Örjan', 'Bengt', 'Ärla'];
const sorted = names.sort((a, b) => a.localeCompare(b, 'sv-SE'));
console.log(sorted);
// Output: ['Anna', 'Bengt', 'Åsa', 'Ärla', 'Örjan']

В шведской локали Anna и Bengt идут первыми, потому что используют стандартные латинские буквы. Затем идут Åsa, Ärla и Örjan с их специальными шведскими буквами в конце.

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

const names = ['Åsa', 'Anna', 'Örjan', 'Bengt', 'Ärla'];
const sorted = names.sort((a, b) => a.localeCompare(b, 'de-DE'));
console.log(sorted);
// Output: ['Anna', 'Ärla', 'Åsa', 'Bengt', 'Örjan']

В немецком языке ä при сортировке считается эквивалентом a. Поэтому Ärla оказывается сразу после Anna, а не в конце, как в шведском.

Когда использовать localeCompare

Используйте localeCompare(), если нужно отсортировать небольшой массив или сравнить две строки. Это простой API, не требующий создания и управления объектом collator.

const items = ['Banana', 'apple', 'Cherry'];
const sorted = items.sort((a, b) => a.localeCompare(b, 'en-US'));
console.log(sorted);
// Output: ['apple', 'Banana', 'Cherry']

Этот подход отлично работает для массивов с несколькими десятками элементов. Для небольших наборов данных разница в производительности несущественна.

Вы также можете использовать localeCompare(), чтобы проверить, идёт ли одна строка перед другой, не сортируя весь массив.

const firstName = 'Åsa';
const secondName = 'Anna';

if (firstName.localeCompare(secondName, 'sv-SE') < 0) {
  console.log(`${firstName} comes before ${secondName}`);
} else {
  console.log(`${secondName} comes before ${firstName}`);
}
// Output: "Anna comes before Åsa"

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

Как Intl.Collator повышает производительность

API Intl.Collator создает функцию сравнения, оптимизированную для многократного использования. При сортировке больших массивов или множественных сравнениях collator работает значительно быстрее, чем вызов localeCompare() для каждого сравнения.

const collator = new Intl.Collator('sv-SE');
const names = ['Åsa', 'Anna', 'Örjan', 'Bengt', 'Ärla'];
const sorted = names.sort(collator.compare);
console.log(sorted);
// Output: ['Anna', 'Bengt', 'Åsa', 'Ärla', 'Örjan']

Свойство collator.compare возвращает функцию сравнения, которая работает напрямую с Array.sort(). Не нужно оборачивать её в стрелочную функцию.

Создание collator один раз и его повторное использование для нескольких операций избавляет от затрат на поиск данных локали при каждом сравнении.

const collator = new Intl.Collator('de-DE');

const germanCities = ['München', 'Berlin', 'Köln', 'Hamburg'];
const sortedCities = germanCities.sort(collator.compare);

const germanNames = ['Müller', 'Schmidt', 'Schröder', 'Fischer'];
const sortedNames = germanNames.sort(collator.compare);

console.log(sortedCities);
// Output: ['Berlin', 'Hamburg', 'Köln', 'München']

console.log(sortedNames);
// Output: ['Fischer', 'Müller', 'Schmidt', 'Schröder']

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

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

Используйте Intl.Collator при сортировке массивов из сотен или тысяч элементов. Преимущество по производительности растет с увеличением размера массива, потому что функция сравнения вызывается много раз во время сортировки.

const collator = new Intl.Collator('en-US');
const products = [/* array with 10,000 product names */];
const sorted = products.sort(collator.compare);

Для массивов больше нескольких сотен элементов collator может быть в несколько раз быстрее, чем localeCompare().

Также используйте Intl.Collator, если нужно сортировать несколько массивов с одной локалью и опциями. Создание collator один раз и его повторное использование избавляет от повторных обращений к данным локали.

const collator = new Intl.Collator('fr-FR');

const firstNames = ['Amélie', 'Bernard', 'Émilie', 'François'];
const lastNames = ['Dubois', 'Martin', 'Lefèvre', 'Bernard'];

const sortedFirstNames = firstNames.sort(collator.compare);
const sortedLastNames = lastNames.sort(collator.compare);

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

Как указать локаль

И localeCompare(), и Intl.Collator принимают идентификатор локали в качестве первого аргумента. Этот идентификатор использует формат BCP 47, обычно сочетая код языка и, при необходимости, код региона.

const names = ['Åsa', 'Anna', 'Ärla'];

// Swedish locale
const swedishSorted = names.sort((a, b) => a.localeCompare(b, 'sv-SE'));
console.log(swedishSorted);
// Output: ['Anna', 'Åsa', 'Ärla']

// German locale
const germanSorted = names.sort((a, b) => a.localeCompare(b, 'de-DE'));
console.log(germanSorted);
// Output: ['Anna', 'Ärla', 'Åsa']

Локаль определяет, какие правила сортировки применяются. В шведском и немецком языках разные правила для å и ä, поэтому порядок сортировки будет отличаться.

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

const collator = new Intl.Collator();
const names = ['Åsa', 'Anna', 'Ärla'];
const sorted = names.sort(collator.compare);

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

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

const collator = new Intl.Collator(['sv-SE', 'sv', 'en-US']);

API использует первую поддерживаемую локаль из массива. Если шведский (Швеция) недоступен, будет использован общий шведский, а затем — американский английский.

Как управлять учётом регистра

Опция sensitivity определяет, как сравнение учитывает различия в регистре и акцентах. Она принимает четыре значения: base, accent, case и variant.

Чувствительность base игнорирует и регистр, и акценты, сравнивая только базовые символы.

const collator = new Intl.Collator('en-US', { sensitivity: 'base' });

console.log(collator.compare('a', 'A'));
// Output: 0 (equal)

console.log(collator.compare('a', 'á'));
// Output: 0 (equal)

console.log(collator.compare('a', 'b'));
// Output: -1 (different base characters)

В этом режиме a, A и á считаются одинаковыми, потому что у них одинаковый базовый символ.

Чувствительность accent учитывает акценты, но игнорирует регистр.

const collator = new Intl.Collator('en-US', { sensitivity: 'accent' });

console.log(collator.compare('a', 'A'));
// Output: 0 (equal, case ignored)

console.log(collator.compare('a', 'á'));
// Output: -1 (different, accent matters)

Чувствительность case учитывает регистр, но игнорирует акценты.

const collator = new Intl.Collator('en-US', { sensitivity: 'case' });

console.log(collator.compare('a', 'A'));
// Output: -1 (different, case matters)

console.log(collator.compare('a', 'á'));
// Output: 0 (equal, accent ignored)

Чувствительность variant (по умолчанию) учитывает все различия.

const collator = new Intl.Collator('en-US', { sensitivity: 'variant' });

console.log(collator.compare('a', 'A'));
// Output: -1 (different)

console.log(collator.compare('a', 'á'));
// Output: -1 (different)

Этот режим обеспечивает самое строгое сравнение, любое различие считается значимым.

Как сортировать строки с числами внутри

Опция numeric включает числовую сортировку для строк, содержащих числа. При включении сравнение воспринимает последовательности цифр как числа, а не просто символы.

const files = ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'];

// Default sorting (wrong order)
const defaultSorted = [...files].sort();
console.log(defaultSorted);
// Output: ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt']

// Numeric sorting (correct order)
const collator = new Intl.Collator('en-US', { numeric: true });
const numericSorted = files.sort(collator.compare);
console.log(numericSorted);
// Output: ['file1.txt', 'file2.txt', 'file10.txt', 'file20.txt']

Без числовой сортировки строки сортируются посимвольно. Строка 10 идёт перед 2, потому что первый символ 1 имеет меньший код, чем 2.

Если включена числовая сортировка, collator распознаёт 10 как число десять, а 2 — как число два. Это даёт ожидаемый порядок сортировки, где 2 идёт перед 10.

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

const versions = ['v1.10', 'v1.2', 'v1.20', 'v1.3'];
const collator = new Intl.Collator('en-US', { numeric: true });
const sorted = versions.sort(collator.compare);
console.log(sorted);
// Output: ['v1.2', 'v1.3', 'v1.10', 'v1.20']

Как управлять порядком сортировки по регистру

Опция caseFirst определяет, какие буквы — заглавные или строчные — будут идти первыми при сравнении строк, отличающихся только регистром. Она принимает три значения: upper, lower или false.

const words = ['apple', 'Apple', 'APPLE'];

// Uppercase first
const upperFirst = new Intl.Collator('en-US', { caseFirst: 'upper' });
const upperSorted = [...words].sort(upperFirst.compare);
console.log(upperSorted);
// Output: ['APPLE', 'Apple', 'apple']

// Lowercase first
const lowerFirst = new Intl.Collator('en-US', { caseFirst: 'lower' });
const lowerSorted = [...words].sort(lowerFirst.compare);
console.log(lowerSorted);
// Output: ['apple', 'Apple', 'APPLE']

// Default (locale-dependent)
const defaultCase = new Intl.Collator('en-US', { caseFirst: 'false' });
const defaultSorted = [...words].sort(defaultCase.compare);
console.log(defaultSorted);
// Output depends on locale

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

Эта опция работает только если опция sensitivity разрешает учитывать различия в регистре.

Как игнорировать пунктуацию при сортировке

Опция ignorePunctuation указывает collator игнорировать знаки препинания при сравнении строк. Это бывает полезно при сортировке заголовков или фраз, которые могут содержать или не содержать пунктуацию.

const titles = [
  'The Old Man',
  'The Old-Man',
  'The Oldman',
];

// Default (punctuation matters)
const defaultCollator = new Intl.Collator('en-US');
const defaultSorted = [...titles].sort(defaultCollator.compare);
console.log(defaultSorted);
// Output: ['The Old Man', 'The Old-Man', 'The Oldman']

// Ignore punctuation
const noPunctCollator = new Intl.Collator('en-US', { ignorePunctuation: true });
const noPunctSorted = [...titles].sort(noPunctCollator.compare);
console.log(noPunctSorted);
// Output: ['The Old Man', 'The Old-Man', 'The Oldman']

Когда пунктуация игнорируется, сравнение воспринимает дефис в «Old-Man» так, как будто его нет, и строки сравниваются как «TheOldMan».

Сортировка имён пользователей из разных стран

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

const userLocale = navigator.language;
const collator = new Intl.Collator(userLocale);

const users = [
  { name: 'Müller', country: 'Germany' },
  { name: 'Martin', country: 'France' },
  { name: 'Andersson', country: 'Sweden' },
  { name: 'García', country: 'Spain' },
];

const sorted = users.sort((a, b) => collator.compare(a.name, b.name));

sorted.forEach(user => {
  console.log(`${user.name} (${user.country})`);
});

Этот код определяет локаль пользователя из браузера и сортирует имена соответственно. Немецкий пользователь увидит список, отсортированный по немецким правилам, а шведский — по шведским.

Сортировка с переключением локали

Если в вашем приложении можно менять язык, обновляйте collator при смене локали.

let currentLocale = 'en-US';
let collator = new Intl.Collator(currentLocale);

function setLocale(newLocale) {
  currentLocale = newLocale;
  collator = new Intl.Collator(currentLocale);
}

function sortItems(items) {
  return items.sort(collator.compare);
}

// User switches to Swedish
setLocale('sv-SE');
const names = ['Åsa', 'Anna', 'Örjan'];
console.log(sortItems(names));
// Output: ['Anna', 'Åsa', 'Örjan']

// User switches to German
setLocale('de-DE');
const germanNames = ['Über', 'Uhr', 'Udo'];
console.log(sortItems(germanNames));
// Output: ['Udo', 'Uhr', 'Über']

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

Как выбрать между localeCompare и Intl.Collator

Используйте localeCompare(), если нужно быстро сравнить строки или отсортировать небольшой массив (меньше 100 элементов). Такой синтаксис проще для чтения, а разница в производительности несущественна для маленьких наборов данных.

const items = ['banana', 'Apple', 'cherry'];
const sorted = items.sort((a, b) => a.localeCompare(b, 'en-US'));

Используйте Intl.Collator, если сортируете большие массивы, делаете много сравнений или сортируете несколько массивов с одной локалью и опциями. Создав collator один раз и используя его повторно, вы получите лучшую производительность.

const collator = new Intl.Collator('en-US', { sensitivity: 'base', numeric: true });

const products = [/* large array */];
const sorted = products.sort(collator.compare);

Оба подхода дают одинаковый результат. Выбор зависит от ваших требований к производительности и предпочтений по организации кода.