Как правильно сортировать строки с числами внутри
Используйте числовую сортировку для правильного порядка файлов, версий и других строк с числами
Введение
Когда вы сортируете строки с числами, ожидаете, что file1.txt, file2.txt и file10.txt будут идти именно в таком порядке. Но стандартное сравнение строк в JavaScript выдаёт file1.txt, file10.txt, file2.txt. Это происходит потому, что строки сравниваются посимвольно, и символ 1 в 10 идёт раньше символа 2.
Такая проблема возникает при сортировке имён файлов, номеров версий, адресов, артикулов и любых других строк с числами внутри. Неправильный порядок сбивает с толку пользователей и усложняет работу с данными.
В JavaScript есть API Intl.Collator с опцией numeric, которая решает эту задачу. В этом уроке расскажем, как работает числовая сортировка, почему стандартное сравнение не подходит и как сортировать строки с числами по-настоящему естественно.
Что такое числовая сортировка
Числовая сортировка — это способ сравнения, при котором последовательности цифр воспринимаются как числа, а не как отдельные символы. При сравнении collator находит такие последовательности и сравнивает их по числовому значению.
Если числовая сортировка выключена, строка file10.txt окажется раньше file2.txt, потому что посимвольное сравнение определяет, что 1 идёт раньше 2 в первой отличающейся позиции. Collator не учитывает, что 10 — это число больше, чем 2.
Если включить числовую сортировку, collator воспринимает 10 и 2 как целые числа и сравнивает их по значению. Так как 10 больше 2, file2.txt правильно идёт перед file10.txt.
Это поведение приводит к так называемой естественной сортировке или естественному порядку, когда строки с числами сортируются так, как ожидают люди, а не строго по алфавиту.
Почему стандартное сравнение строк не работает с числами
Стандартное сравнение строк в JavaScript использует лексикографический порядок, сравнивая строки посимвольно слева направо по значениям кодовых точек Unicode. Это работает корректно для алфавитного текста, но даёт неожиданные результаты для чисел.
Посмотрите, как лексикографическое сравнение обрабатывает эти строки:
const files = ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'];
files.sort();
console.log(files);
// Output: ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt']
Сравнение рассматривает каждую позицию символа отдельно. В первой отличающейся позиции после file оно сравнивает 1 с 2. Поскольку у 1 меньшее значение Unicode, чем у 2, любая строка, начинающаяся с file1, будет идти перед строкой, начинающейся с file2, независимо от того, что идёт дальше.
В результате получается последовательность file1.txt, file10.txt, file2.txt, file20.txt, что не соответствует человеческим ожиданиям по поводу порядка чисел.
Использование Intl.Collator с опцией numeric
Конструктор Intl.Collator принимает объект опций с параметром numeric. Установка numeric: true включает числовую сортировку, и коллатор сравнивает последовательности цифр по их числовому значению.
const collator = new Intl.Collator('en-US', { numeric: true });
const files = ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'];
files.sort(collator.compare);
console.log(files);
// Output: ['file1.txt', 'file2.txt', 'file10.txt', 'file20.txt']
Метод compare коллатора возвращает отрицательное число, если первый аргумент должен идти перед вторым, ноль — если они равны, и положительное число — если первый должен идти после второго. Это соответствует сигнатуре, которую ожидает метод JavaScript Array.sort().
Отсортированный результат располагает файлы в естественном числовом порядке. Коллатор понимает, что 1 < 2 < 10 < 20, и выдаёт последовательность, ожидаемую человеком.
Сортировка смешанных алфавитно-цифровых строк
Числовая сортировка работает со строками, где числа могут встречаться в любой позиции, а не только в конце. Коллатор сравнивает буквенные части как обычно, а числовые — по их значению.
const collator = new Intl.Collator('en-US', { numeric: true });
const addresses = ['123 Oak St', '45 Oak St', '1234 Oak St', '5 Oak St'];
addresses.sort(collator.compare);
console.log(addresses);
// Output: ['5 Oak St', '45 Oak St', '123 Oak St', '1234 Oak St']
Коллатор определяет последовательности цифр в начале каждой строки и сравнивает их по числовому значению. Он понимает, что 5 < 45 < 123 < 1234, хотя лексикографическое сравнение дало бы другой порядок.
Сортировка номеров версий
Номера версий — частый пример использования числовой сортировки. Версии ПО вроде 1.2.10 должны идти после 1.2.2, но лексикографическая сортировка даст неверный порядок.
const collator = new Intl.Collator('en-US', { numeric: true });
const versions = ['1.2.10', '1.2.2', '1.10.5', '1.2.5'];
versions.sort(collator.compare);
console.log(versions);
// Output: ['1.2.2', '1.2.5', '1.2.10', '1.10.5']
Коллатор правильно сравнивает каждую числовую часть. В последовательности 1.2.2, 1.2.5, 1.2.10 он определяет, что третья часть увеличивается по числу. В 1.10.5 он видит, что вторая часть — это 10, что больше, чем 2.
Сортировка товарных кодов и идентификаторов
Товарные коды, номера счетов и другие идентификаторы часто содержат буквы и цифры. Числовая сортировка обеспечивает их логичный порядок.
const collator = new Intl.Collator('en-US', { numeric: true });
const products = ['PROD-1', 'PROD-10', 'PROD-2', 'PROD-100'];
products.sort(collator.compare);
console.log(products);
// Output: ['PROD-1', 'PROD-2', 'PROD-10', 'PROD-100']
Буквенный префикс PROD- совпадает во всех строках, поэтому коллатор сравнивает числовой суффикс. В результате строки сортируются по возрастанию числового значения, а не лексикографически.
Сортировка с разными локалями
Опция numeric работает с любой локалью. Хотя в разных локалях могут быть свои правила сортировки букв, числовое сравнение всегда одинаковое.
const enCollator = new Intl.Collator('en-US', { numeric: true });
const deCollator = new Intl.Collator('de-DE', { numeric: true });
const items = ['item1', 'item10', 'item2'];
console.log(items.sort(enCollator.compare));
// Output: ['item1', 'item2', 'item10']
console.log(items.sort(deCollator.compare));
// Output: ['item1', 'item2', 'item10']
Обе локали дадут одинаковый результат, потому что строки содержат только символы ASCII и цифры. Если в строках есть специфичные для локали символы, буквенная часть сортируется по правилам локали, а числовая — всегда одинаково.
Сравнение строк без сортировки
Вы можете напрямую использовать метод compare коллатора, чтобы определить взаимное расположение двух строк без сортировки всего массива.
const collator = new Intl.Collator('en-US', { numeric: true });
console.log(collator.compare('file2.txt', 'file10.txt'));
// Output: -1 (negative number means first argument comes before second)
console.log(collator.compare('file10.txt', 'file2.txt'));
// Output: 1 (positive number means first argument comes after second)
console.log(collator.compare('file2.txt', 'file2.txt'));
// Output: 0 (zero means arguments are equal)
Это удобно, если нужно проверить порядок без изменения массива, например, при вставке элемента в отсортированный список или при проверке, попадает ли значение в диапазон.
Ограничения при работе с десятичными числами
Числовая сортировка сравнивает только последовательности цифр и не воспринимает точку как часть числа. Символ точки считается разделителем, а не десятичным знаком.
const collator = new Intl.Collator('en-US', { numeric: true });
const measurements = ['0.5', '0.05', '0.005'];
measurements.sort(collator.compare);
console.log(measurements);
// Output: ['0.005', '0.05', '0.5']
Коллатор воспринимает каждое значение как три отдельных компонента: часть до точки, саму точку и часть после точки. Он сравнивает 0 с 0 (равны), затем сравнивает части после точки как отдельные числа: 5, 5 и 5 (равны). Потом сравнивает второй десятичный разряд: ничего, 5 и ничего. Это приводит к неправильному порядку для десятичных чисел.
Чтобы корректно сортировать десятичные числа, преобразуйте их в числа и сортируйте по значению или используйте дополнение строк нулями для правильного лексикографического порядка.
Комбинирование числовой сортировки с другими опциями
Опция numeric работает вместе с другими параметрами сортировки, такими как sensitivity и caseFirst. Вы можете управлять тем, как коллатор обрабатывает регистр и акценты, сохраняя при этом числовое сравнение.
const collator = new Intl.Collator('en-US', {
numeric: true,
sensitivity: 'base'
});
const items = ['Item1', 'item10', 'ITEM2'];
items.sort(collator.compare);
console.log(items);
// Output: ['Item1', 'ITEM2', 'item10']
Опция sensitivity: 'base' делает сравнение нечувствительным к регистру. Коллатор считает Item1, item1 и ITEM1 эквивалентными, при этом числовые части сравниваются корректно.
Повторное использование collator-ов для повышения производительности
Создание нового экземпляра Intl.Collator включает загрузку данных локали и обработку опций. Если нужно отсортировать несколько массивов или выполнить много сравнений, создайте collator один раз и используйте его повторно.
const collator = new Intl.Collator('en-US', { numeric: true });
const files = ['file1.txt', 'file10.txt', 'file2.txt'];
const versions = ['1.2.10', '1.2.2', '1.10.5'];
const products = ['PROD-1', 'PROD-10', 'PROD-2'];
files.sort(collator.compare);
versions.sort(collator.compare);
products.sort(collator.compare);
Такой подход эффективнее, чем создание нового collator для каждой сортировки. Разница в производительности становится заметной при сортировке большого количества массивов или частых сравнениях.