如何正确排序包含数字的字符串

使用数字排序规则(numeric collation)以自然顺序对文件名、版本号及其他包含数字的字符串进行排序

简介

当你对包含数字的字符串进行排序时,期望 file1.txtfile2.txtfile10.txt 按照这个顺序排列。然而,JavaScript 默认的字符串比较会得到 file1.txtfile10.txtfile2.txt。出现这种情况,是因为字符串是逐字符比较的,且 10 中的字符 1 排在 2 之前。

无论是排序文件名、版本号、街道地址、产品编码,还是任何包含数字的字符串时,这个问题都会出现。错误的排序会让用户困惑,并导致数据难以查找。

JavaScript 提供了 Intl.Collator API,并带有 numeric 选项,可以解决这个问题。本课程将介绍数字排序规则的工作原理、默认字符串比较为何会失败,以及如何以自然数值顺序对包含数字的字符串进行排序。

什么是数字排序规则

数字排序是一种将数字序列视为整体数字而非单个字符进行比较的方法。在比较字符串时,排序器会识别出数字序列,并按其数值大小进行比较。

如果未启用数字排序,字符串 file10.txt 会排在 file2.txt 前面,因为逐字符比较时,首先不同的字符 1 排在 2 之前。排序器不会考虑 10 代表的数字比 2 大。

启用数字排序后,排序器会将 102 识别为完整数字,并按数值进行比较。由于 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 之后的第一个不同字符处,会将 12 进行比较。由于 1 的 Unicode 值低于 2,因此任何以 file1 开头的字符串都会排在以 file2 开头的字符串之前,无论后面是什么内容。

这会产生 file1.txtfile10.txtfile2.txtfile20.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.21.2.51.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']

排序器会将每个数值视为三个独立的数字部分:小数点前的部分、小数点本身以及小数点后的部分。它会先比较 00(相等),然后将小数点后的部分作为独立数字比较:5、5 和 5(相等)。接着比较第二位小数:无、5、无。这样会导致小数排序结果不正确。

要对小数进行排序,请将其转换为实际数字后按数值排序,或使用字符串补零以确保字典序正确。

数字排序与其他选项的结合

numeric 选项可以与 sensitivitycaseFirst 等其他排序选项配合使用。你可以在保持数字比较行为的同时,控制排序器如何处理大小写和重音符号。

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' 选项会使比较忽略大小写。排序器会将 Item1item1ITEM1 视为等价,同时仍能正确比较数字部分。

复用 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 更高效。当需要排序大量数组或频繁比较时,性能差异会更加明显。