一个直接调用数组 sort() 给价格排序的写法,把 10 排到了 2 前面,让整个排行榜的顺序彻底乱掉:一次 JavaScript 默认排序规则的深度复盘
那个 bug 是测试同学截图发我的,我看了第一眼以为他在开玩笑:一个商品价格从低到高的排序列表,顺序居然是 1, 10, 100, 11, 2, 21, 3——10 排在了 2 的前面,100 排在了 11 前面。这哪是从低到高,这简直是"乱序"。可我的代码明明写的是 prices.sort(),JavaScript 内置的排序,怎么会连数字大小都排不对?我盯着这个再眼熟不过的 sort() 看了好一会,才终于想起一个被我遗忘已久的细节,后背一凉:JavaScript 的数组 sort() 方法,在不传比较函数时,默认是把所有元素先转成字符串,再按字符串的 Unicode 码位(字典序)来比较的!所以它比较 10 和 2 时,比的是字符串 "10" 和 "2"——按字典序,"1" 这个字符排在 "2" 前面,于是 "10" 就被排到了 "2" 的前面。我以为 sort() 会"聪明地"按数字大小排,可它默认压根不看数值,只看字符串。这篇就把这次"数组 sort 默认字典序排序"的坑,从头到尾复盘一遍。
故障现场:一个不带比较函数的 sort()
问题代码,是一个几乎人人都写过的"给数字数组排序":
// ✗ 出问题的代码: 直接 sort(), 不传比较函数
const prices = [1, 10, 100, 11, 2, 21, 3];
prices.sort();
console.log(prices);
// 期望: [1, 2, 3, 10, 11, 21, 100]
// 实际: [1, 10, 100, 11, 2, 21, 3] ✗ 完全乱了!
// 为什么? sort() 不传比较函数时, 默认【把元素转成字符串, 按字典序(Unicode码位)比较】:
// 比较过程相当于比字符串:
// "1" < "10" < "100" < "11" < "2" < "21" < "3"
// (逐字符比: "10"和"2"比, 先比第一个字符 "1"(49) vs "2"(50), "1"小 → "10"排前面)
// → 所以 10 排在了 2 前面, 100 排在了 11 前面。
// 更坑的例子:
console.log([3, 20, 100, 9].sort()); // [100, 20, 3, 9] ✗
console.log(["b", "a", "c"].sort()); // ["a","b","c"] ✓ (字符串排序碰巧是对的)
console.log([10, 9].sort()); // [10, 9] ✗ ("10" < "9")
// 甚至负数、小数也会乱:
console.log([-1, -10, -2].sort()); // [-1, -10, -2] ✗
// 关键认知: Array.prototype.sort() 默认排序 = "按字符串字典序", 不是"按数值大小"!
// 它"恰好"能正确排字符串, 但对数字(以及任何非字符串)几乎总是错的。
第一次重新认识到 sort() 的默认行为时,我哭笑不得:"一个连数字都排不对的排序方法,居然是语言内置的默认行为?"这个坑最坑人的地方在于:它在小数据、个位数时"看起来是对的"——[3, 1, 2].sort() 得到 [1, 2, 3],完全正确(因为个位数的字典序和数值序恰好一致);于是你测试时随手用几个个位数一试,"没问题",就放心上线了。可一旦数据里出现了两位数及以上(10、100),字典序和数值序就分道扬镳,bug 才暴露。这种"小数据下伪装正确、大数据下原形毕露"的特性,让它极易躲过简单的测试、潜入生产。下面就来拆解,sort 的默认规则到底是怎么回事。
第一件事:搞懂 sort() 的默认比较规则
我认真重读了 Array.prototype.sort 的规范,才彻底理解这个"反直觉"的默认行为。
Array.prototype.sort() 的默认行为, 到底是什么?
【核心: 不传比较函数时, sort把每个元素【转成字符串】, 按【Unicode码位(字典序)】排序】
1. sort() 的两种用法:
- arr.sort() → 不传比较函数, 用【默认规则】;
- arr.sort(compareFn) → 传一个比较函数, 按你的规则排。
2. 默认规则(不传比较函数)是怎样的:
- 把每个元素都【转换成字符串】(调用 String(元素));
- 然后按字符串的【Unicode码位顺序】(俗称字典序/字母序)比较;
- → 对数字而言, [1,10,2] 变成比较 "1","10","2", 得到 "1"<"10"<"2"。
3. 为什么字典序对数字是错的:
- 字典序是【逐字符】从左往右比的(像查字典);
- "10" vs "2": 先比第一个字符 '1'(码位49) vs '2'(码位50), '1'小 → "10"在前;
- 它根本【没看数值大小】, 只看"字符串第一位谁的码小";
- → 所以 10 < 2(在字典序里), 排序全乱。
4. 比较函数 compareFn(a, b) 的约定:
- 返回【负数】: a 排在 b 前面;
- 返回【正数】: a 排在 b 后面;
- 返回【0】: a、b 顺序不变。
- 所以数字升序: (a, b) => a - b
(ab时为正→a在后; 正是数值升序)
5. sort 还有两个要注意的点:
- 它是【原地排序】(in-place): 会【改变原数组】, 同时返回原数组的引用;
- ES2019起规范要求sort【稳定】(相等元素保持原相对顺序), 旧环境可能不稳定。
一句话: sort()默认按"元素转字符串后的字典序"排, 不是按数值; 排数字必须传比较函数 (a,b)=>a-b;
它原地修改原数组; 默认行为对数字几乎总是错的, 只是在个位数时碰巧看着对。
这套规则,是整个坑的根。sort() 不传比较函数时,把每个元素转成字符串(String(元素)),再按字符串的 Unicode 码位(字典序)比较。而字典序对数字是错的:它逐字符从左往右比(像查字典),"10" vs "2" 先比第一个字符 '1'(码位 49)和 '2'(码位 50),'1' 小所以 "10" 排前面——它根本没看数值大小,只看字符串第一位谁的码小。正解靠比较函数:compareFn(a,b) 返回负数则 a 在前、正数则 a 在后、0 则不变;所以数字升序就是 (a,b)=>a-b。还要注意两点:sort 是原地排序(改变原数组、返回原数组引用);ES2019 起要求稳定(相等元素保持原序),旧环境可能不稳定。一句话:sort() 默认按"元素转字符串后的字典序"排、不是按数值;排数字必须传 (a,b)=>a-b;它原地修改原数组;默认行为对数字几乎总错,只在个位数时碰巧看着对。
第二件事:正解——给 sort 传比较函数,并注意它原地修改原数组
搞懂了原理,正解就清晰了:排数字一定传比较函数 (a,b)=>a-b;排对象按某字段排也传对应比较函数;不想改原数组就先拷贝;字符串本地化排序用 localeCompare。
// ====== 正解一: 数字排序, 传比较函数 (a, b) => a - b ======
const prices = [1, 10, 100, 11, 2, 21, 3];
prices.sort((a, b) => a - b); // ✓ 升序
console.log(prices); // [1, 2, 3, 10, 11, 21, 100] ✓
prices.sort((a, b) => b - a); // ✓ 降序 [100, 21, 11, 10, 3, 2, 1]
// ====== 正解二: 按对象的某个字段排序 ======
const products = [
{ name: "A", price: 100 },
{ name: "B", price: 9 },
{ name: "C", price: 50 },
];
products.sort((a, b) => a.price - b.price); // ✓ 按price升序
// 多级排序: 先按价格, 价格相同再按名字
products.sort((a, b) =>
a.price - b.price || a.name.localeCompare(b.name)
);
// ====== 正解三: 不想改原数组? 先拷贝再排(sort会改原数组!) ======
const original = [3, 1, 2];
const sorted = [...original].sort((a, b) => a - b); // ✓ 拷贝后排, 不动original
console.log(original); // [3, 1, 2] 没变
// ES2023+ 还有不可变版本: original.toSorted((a,b)=>a-b) 返回新数组、不改原数组
// ====== 正解四: 字符串排序(尤其含中文/带语言习惯), 用 localeCompare ======
const names = ["张三", "李四", "王五"];
names.sort((a, b) => a.localeCompare(b, "zh")); // ✓ 按中文习惯排序
// 纯ASCII字符串 arr.sort() 默认能对, 但涉及大小写/重音/中文时用 localeCompare 更准。
// 核心: 排数字传(a,b)=>a-b; 排对象按字段传比较函数; 多级排序用 || 串联;
// sort原地改原数组, 不想改就先[...拷贝]或用toSorted; 字符串本地化排序用localeCompare。
修复的核心,是"永远显式告诉 sort 你想怎么比"。正解一:数字排序传 (a,b)=>a-b(升序)或 b-a(降序)。正解二:按对象字段排传对应比较函数(a.price-b.price),多级排序用 || 串联(价格相同再比名字)。正解三:不想改原数组就先拷贝([...arr].sort(...))或用 ES2023 的 toSorted(返回新数组、不改原数组),因为 sort 会原地修改原数组。正解四:字符串(尤其含中文/大小写/重音)用 localeCompare(纯 ASCII 默认能对,涉及语言习惯时更准)。归根结底:排数字传 (a,b)=>a-b、排对象按字段传比较函数、多级排序用 || 串联、不想改原数组就拷贝或用 toSorted、字符串本地化用 localeCompare。
第三件事:JavaScript 里其他"反直觉"的隐式转换坑
排查后我把 JS 里其他和"隐式转换/默认行为反直觉"相关的常见坑也系统梳理了一遍。
JavaScript 其他反直觉的坑
# 1. sort默认字典序(本文): 排数字要传(a,b)=>a-b。
# 2. == 的隐式转换: 0 == "" 为false但 0 == "0"为true, null == undefined为true。
# → 一律用 === 严格相等, 避开隐式转换的迷惑。
# 3. + 的字符串拼接: 1 + "2" = "12"(数字被转字符串), 但 1 - "2" = -1。→ 注意+的二义性。
# 4. NaN: NaN === NaN 为false; typeof NaN 是 "number"。→ 用 Number.isNaN 判断。
# 5. 浮点精度: 0.1 + 0.2 !== 0.3(同其他语言)。→ 钱用整数分/库, 比较用容差。
# 6. parseInt 的坑: parseInt("08") 老引擎按八进制; [1,2,3].map(parseInt) 结果诡异。
# → parseInt 显式传基数 parseInt(x, 10); map别直接传parseInt。
# 7. 数组空洞与length: arr[100]=1 会让length变101; map/forEach跳过空洞。
# 8. typeof null === "object": 历史遗留bug。→ 判null用 x === null。
# 共同根源: JS有一套复杂的【隐式类型转换】规则和一些【为方便而设的默认行为】;
# 它们在简单场景"贴心"、在边界场景"坑人", 且很多是历史包袱无法改动。
# 核心: 了解JS这些隐式转换/默认行为的"脾气"; 用===而非==; 排序传比较函数;
# 显式胜于隐式(显式转换、显式基数、显式比较), 别依赖那些反直觉的默认。
排查让我把 JS 的其他坑也梳理清了。一、sort 默认字典序(本文)。二、== 的隐式转换(用 === 避开)。三、+ 的字符串拼接二义性。四、NaN(NaN!==NaN,用 Number.isNaN)。五、浮点精度。六、parseInt 的坑(显式传基数、map 别直接传 parseInt)。七、数组空洞与 length。八、typeof null === "object"。它们的共同根源是:JS 有一套复杂的隐式类型转换规则和一些为方便而设的默认行为;它们在简单场景"贴心"、边界场景"坑人",且很多是历史包袱无法改动。核心是:了解 JS 这些隐式转换/默认行为的"脾气";用 === 而非 ==;排序传比较函数;显式胜于隐式(显式转换、显式基数、显式比较),别依赖反直觉的默认。下面这张图,是这次 sort 默认排序坑的成因与解法:
第四件事:sort 比较函数速查表
这次踩坑后,我把常见排序需求对应的比较函数整理成一张表,随用随查。
| 需求 | 比较函数 | 说明 |
|---|---|---|
| 数字升序 | (a, b) => a - b | 最常用 |
| 数字降序 | (a, b) => b - a | 调换 a b |
| 按对象数字字段 | (a, b) => a.age - b.age | 取字段相减 |
| 按对象字符串字段 | (a, b) => a.name.localeCompare(b.name) | 本地化 |
| 多级排序 | (a, b) => a.x - b.x || a.y - b.y | || 串联 |
| 字符串本地化 | (a, b) => a.localeCompare(b, "zh") | 中文/重音 |
这张表把排序比较函数钉清了。核心是:sort 的灵活性全在那个比较函数里——你想按什么排、升还是降、多字段怎么排,都通过这个 (a,b)=>... 显式表达;掌握"返回负数 a 在前、正数 a 在后"这一条约定,所有排序需求都能拼出来(数字相减、字符串 localeCompare、多级用 || 串联)。它给我的最大启发是:很多 API 都有一个"默认行为"和一个"可定制的钩子"——sort() 的默认行为(字典序)只覆盖最狭窄的场景,而它真正的威力在那个可传入的比较函数;真正用好一个 API,不能只会它"不传参的默认调法",而要理解它留给你定制的那个口子、并在需要时显式地用它。这其实是用好工具的一条通则:"默认值是给最简单情况的兜底,不是给你具体场景的答案"——遇到默认行为不符预期时,第一反应该是"这个 API 是不是有个参数/钩子能让我定制?",而不是"它怎么这么蠢"或绕着写一堆 workaround;读文档、看清 API 提供的定制能力,往往比自己硬造轮子优雅得多。掌握 sort 比较函数、善用 API 留给你的定制钩子——是这个坑带给我的、关于用好 API 的认知。
第五件事:这个坑暴露的"测试覆盖"问题
这次最值得反思的,是它为什么能溜到生产。我把"为什么测试没拦住它"分析成表。
| 原因 | 说明 | 改进 |
|---|---|---|
| 只测了个位数 | [1,2,3]个位数字典序碰巧对 | 测试要含两位数/多位数 |
| 没测边界值 | 没测10、100、负数 | 覆盖跨数量级的值 |
| 没断言完整顺序 | 只看了"排了序"没看对不对 | 断言完整期望数组 |
| 用例数据太"友好" | 恰好挑了不会暴露bug的数 | 用真实/随机/极端数据 |
| 没有回归测试 | 改了排序没自动验证 | 核心逻辑加单测 |
这张表道出了一个比 sort 本身更重要的教训。核心是:这个 bug 能溜到生产,根子在测试用例的数据"太友好"——我们(下意识地)用了 [1,2,3] 这种恰好不会暴露 bug 的个位数据,而没用 [2,10,1] 这种能区分"字典序"和"数值序"的数据;"测了"不等于"测到了"——用例的数据选得好不好,直接决定了测试有没有真正覆盖到风险。它给我的深刻启发是:写测试用例,关键不在"测了多少条",而在"每条用例的数据,能不能区分'对的实现'和'错的实现'"——一个好的测试用例,应该是"如果实现错了,它一定会失败"的;像 [1,2,3] 这种"无论字典序还是数值序结果都一样"的数据,区分不出对错,等于没测到这个点;测试要主动地、刻意地挑选那些"能让错误暴露"的数据(边界、跨量级、易混淆的值)。这让我对测试有了更深的认识:测试的价值,在于它的"证伪能力"——能不能在实现有 bug 时让你知道;追求的不是"测试通过"带来的虚假安心,而是"用足够刁钻的数据去试图攻破它"的真实信心。用能区分对错的刁钻数据写测试、追求测试的证伪能力——是这个 sort 坑,从测试层面给我的宝贵一课。
第六件事:我现在封装了一套排序工具
为了让团队不再裸用 sort(),我封装了一套语义清晰的排序工具,把"正确的排序"变成默认、好用的选择。
// 一套语义清晰、不改原数组的排序工具
const sortBy = {
// 数字升序(不改原数组)
numberAsc: (arr) => [...arr].sort((a, b) => a - b),
numberDesc: (arr) => [...arr].sort((a, b) => b - a),
// 按对象数字字段
byNumberField: (arr, key) => [...arr].sort((a, b) => a[key] - b[key]),
// 按对象字符串字段(本地化)
byStringField: (arr, key, locale = "zh") =>
[...arr].sort((a, b) => String(a[key]).localeCompare(String(b[key]), locale)),
// 多级排序: 传入多个 (a,b)=>number 的比较器, 依次生效
byMulti: (arr, ...cmps) =>
[...arr].sort((a, b) => {
for (const cmp of cmps) {
const r = cmp(a, b);
if (r !== 0) return r;
}
return 0;
}),
};
// 用法: 语义清晰, 还不会误改原数组
sortBy.numberAsc([2, 10, 1]); // [1, 2, 10] ✓
sortBy.byNumberField(products, "price"); // 按price升序
sortBy.byMulti(products,
(a, b) => a.price - b.price, // 先按价格
(a, b) => a.name.localeCompare(b.name)); // 价格同再按名字
这套工具,把"容易踩坑的裸 sort"封装成了"语义清晰、不会出错"的方法。核心是:与其让每个人去记"sort 要传比较函数、要 a-b、会改原数组"这些容易忘的细节,不如封装成 sortBy.numberAsc() 这种意图明确、内置了正确做法、且不改原数组的工具——让"排数字"这个意图,直接对应一个"一定排得对"的方法。它再次印证了我反复体会的一条工程智慧:"让正确的做法成为最容易、最自然的选择"——通过封装把"正确实践"固化下来、把"易错的细节"藏进去,远比"反复叮嘱大家小心"可靠;好的封装,不只是"少写代码",更是"把对的做法变成默认、把错的做法变得难以发生"。用语义化封装固化正确的排序——是这个坑从团队协作层面教我的收尾一招。下面这张图,是我现在面对排序的决策习惯:
这张图的精髓,是"排什么传对应的比较函数,要保留原数组就先拷贝"。排数字/对象/字符串/多字段都显式传对应比较函数(绝不裸 sort 排数字);要保留原数组就先拷贝或用 toSorted。这套习惯,让我从"排序随手 sort()"变成了"排序先想比较函数和原数组"——核心始终是:sort 默认按字典序、会改原数组;排数字必传 (a,b)=>a-b,要保留原数组就先拷贝。
我立下的几条规矩
这场"sort 默认字典序、排行榜乱套"的事故,换来了我写 JavaScript 时,刻进骨子里的几条铁律:
- sort() 默认按字符串字典序排,不是数值。排数字必须传 (a,b)=>a-b。
- sort() 原地修改原数组。不想改就先 [...拷贝] 或用 toSorted。
- 排对象按字段传比较函数。数字字段相减,字符串字段 localeCompare。
- 多级排序用 || 串联比较器。前一级相等(返回0)才看下一级。
- 字符串本地化排序用 localeCompare。含中文/大小写/重音时尤其要。
- 测试用能区分对错的数据。排序测试要含两位数、跨量级、易混淆的值。
- 显式胜于隐式。别依赖语言反直觉的默认行为,显式表达意图。
附:为什么这种"语言级默认坑"值得专门记住
这次之后,我专门留意了一下:像 sort 这样"语言内置默认行为反直觉"的坑,在各语言里其实成片地存在,它们有一类共同的特征,值得专门警惕。
这类"语言级默认坑"的共同特征,值得我们专门记住、专门设防。它们的共性是:一、它是"内置的、默认的"——你不会怀疑一个语言自带的基础方法会"错";二、它在简单场景"看着对"——给了你虚假的信任,让你不去深究;三、它的"错"其实是"设计如此"——sort 默认字典序、float 不精确、== 隐式转换,都不是 bug,而是有其历史原因的、明确的设计,只是不符合你的直觉。它给我的总结是:每门语言都有一份这样的"反直觉默认行为"清单(JS 尤其长)——把你常用语言的这份"坑清单"主动地学一遍、记下来(读《You Don't Know JS》这类书、看语言的"常见陷阱"总结),是一笔回报极高的投资;因为这些坑普遍、隐蔽、且会反复出现,提前知道一个,就能少踩一个。这其实是从"会用一门语言"到"真正吃透一门语言"的必经之路:不仅要知道它的特性"怎么用",还要知道它的特性"在哪里会咬人"——熟悉一门语言的"阴暗面(gotchas)",和熟悉它的"闪光点(features)"同样重要;一个真正熟练的工程师,脑子里装的不只是"这门语言能做什么",还有"这门语言会在哪些地方坑我"。主动学习并记住语言的反直觉默认坑清单——是这个 sort 坑,推着我去做的一项长期功课。
写在最后
回头看,这场由"一个不带比较函数的 sort()"引发的、排行榜乱套的事故,真正教给我的,远不止"sort 排数字要传 (a,b)=>a-b"这一个技巧。它让我对"'我以为它会怎样',和'它实际怎样',之间的鸿沟,是 bug 最爱藏身的地方",有了一次刻骨的体会。我栽跟头,根源在于一个未经验证的假设:我"想当然地"以为 sort() 会"聪明地"按数值大小排序——因为这"显然"是我想要的、也"显然"是排序"该有"的样子。我把"我期望它做什么",当成了"它实际会做什么",而从没去验证过这个假设。可 sort() 的实际行为(字典序),和我的期望(数值序),是两回事;而它"恰好"在我测试用的个位数上表现一致,更是纵容了我这个错误假设——它给了我"看,果然如我所想"的虚假印证,让我更加确信了一个错误的认知。这让我领悟到一个关于工程严谨性的深刻认知:程序员最危险的,不是"不知道",而是"自以为知道、却没验证"——那些"我觉得它就是这样""这还用试吗"的想当然的假设,因为从未被拿出来检验,就成了潜伏最深的隐患;尤其当现实"碰巧"在简单情况下印证了你的错误假设时,这个假设会被你更牢固地误信,直到在某个边界情况下轰然崩塌。这给了我一种近乎本能的警觉:对待自己"想当然"的认知,要有一份"我真的验证过吗?"的自省——尤其是对那些"用了很多年、从没细想过"的基础 API/行为,偶尔停下来亲手 console.log 一下、查一下文档、验证一下它的真实行为,而不是凭着模糊的、可能早已错误的印象去用它;"不要假设,去验证(Don't assume, verify)"——这五个字,值得贴在每个程序员的显示器上。警惕想当然的假设、对自以为知道的事去亲手验证——这,是我用一次 sort 排序乱套的事故,换来的、关于 JavaScript、也关于如何保持工程严谨的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 arr.sort() 给数字排序时,顿一下、补上那个 (a, b) => a - b,那我对着那个乱套的排行榜排查的这段时间,就值了。
—— 别看了 · 2026