先说一个你可能见过的现象:同一行 sort 比较函数,在旧环境里排得好好的,升级 Node 之后就乱了,而且没人改过业务代码。
2023 年 11 月我碰到一次。一个后台订单查询页,用户在页面上点「按创建时间倒序」,Node 18 的预发环境返回顺序是乱的,Node 8 的老生产环境返回顺序是对的。同一批 137 条记录,同一个数据库快照,同一次 SQL。我先用 curl 绕过前端直接打接口打印原始 JSON,顺序确实是乱的。第一反应是 ORM 吞了排序字段,把排序键换掉,没用;改成 ORDER BY id DESC,好了——说明乱序发生在数据拿到之后。翻那个 service 文件,第 84 行:
list.sort((a, b) => a.createdAt > b.createdAt)
这行是 2019 年写的,四年里没人碰过,作者早就离职了。
这行代码在旧环境里真的是对的,不是巧合
如果你打算拿这个例子去面试别人,先别急。V8 在 7.0 版本(对应 Chrome 70、Node 11.0)把 Array.prototype.sort 的底层实现从 QuickSort 换成了 TimSort。V8 官方那篇《V8 release v7.0》里明确写了这一条,理由就是原来的快排不稳定,issue 里被抱怨了很久。TimSort 是稳定的归并类算法,对小数组还有专门的二分插入排序优化。
关键就在这:TimSort 遇到「比较函数返回 0」时保持原顺序,也就是不动。而老的 QuickSort 实现在数组很短的时候走的是插入排序路径,插入排序恰好也稳定。所以那行 a.createdAt > b.createdAt 返回一堆 0 的时候,两种实现都是不交换,看起来都对。直到数据量、元素原始顺序、引擎版本三个变量稍微一变,TimSort 的分区策略跟插入排序不一样了,错误才暴露出来。
说白了一句话:这四年来它不是没 bug,是 bug 一直在装死。换成另一个数据量,或者另一个字段,它随时能再装死一次。
比较函数从来就不是返回布尔值的地方,但 JS 不会拦你
Array.prototype.sort 的比较函数契约其实很清楚:返回负数表示 a 排在 b 前面,返回正数表示 b 排在 a 前面,返回 0 表示两者顺序无所谓(稳定排序下保持原有相对顺序)。
当你写 a > b,返回的是布尔值。JS 会把 true 隐式转成 1,false 转成 0,所以这个比较函数永远只返回 1 和 0,永远不会返回负数。后果是:只要 a 不比 b 大,函数就告诉引擎这俩相等。一个长度 n 的数组里绝大多数比较都返回 0,排序算法彻底失去判断依据,剩下的就是一锅粥。
有个反常识的点:这个问题在 TypeScript 项目里基本见不到。因为 sort 的类型签名是 (compareFn?: (a: T, b: T) => number),你写 (a, b) => a.x > b.x 会直接报 Type 'boolean' is not assignable to type 'number'。我这两个月问了七个遇到类似问题的同事,六个项目是纯 JS,或者用 @ts-ignore 把检查关掉了。
顺便把版本分界线列一下
| Node 版本 | V8 版本 | sort 实现 | 是否稳定 |
|---|---|---|---|
| 8.x | 6.x | QuickSort | 否 |
| 10.x | 6.8 | QuickSort | 否 |
| 11.x | 7.0 | TimSort | 是 |
| 12.x | 7.4 | TimSort | 是 |
| 16.x | 9.0+ | TimSort | 是 |
| 18.x | 10.1+ | TimSort | 是 |
| 20.x | 11.3+ | TimSort | 是 |
Chrome 这边的分界线是 70(2018 年 9 月发布),Safari 和 Firefox 更早。Firefox 的 SpiderMonkey 一直用归并排序,所以 Mac 上的开发者可能从来没遇到过——这也是为什么这类 bug 常常在换服务器或者换 CI 镜像之后才冒出来。另外,规范层面要求 sort 必须稳定是 ES2019 才写进去的,之前是 implementation-defined。严格讲,Node 10 上那个乱序结果完全合法,你连 bug 都提不了。
我跑了 100 万条,数据贴在这
环境是 Node v20.11.1,Apple M1 Pro,32G 内存,macOS 14.5。每种数据跑 10 次取中位数,用 performance.now() 包住 sort 调用本身,数据用 mulberry32 生成,避免 Math.random 的分布偏差。
| 数据类型 | 元素数 | 耗时中位数 |
|---|---|---|
| 随机整数 | 10,000 | 1.2 ms |
| 随机整数 | 100,000 | 14 ms |
| 随机整数 | 1,000,000 | 178 ms |
| 已升序整数 | 1,000,000 | 6 ms |
| 完全降序整数 | 1,000,000 | 9 ms |
| 字符串 localeCompare | 100,000 | 940 ms |
最后一行单独看一眼。砍到 10 万条只是因为 100 万条用 localeCompare 跑了两分多钟我等不下去。同一个数组,把 (a, b) => a.name.localeCompare(b.name) 换成 (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),10 万条从 940 ms 掉到 41 ms,将近 22 倍。
localeCompare 慢的原因不在算法,是它每次调用都要走一遍 ICU 的本地化规则和 collation。如果你确实需要中文按拼音排,别裸调,建一个 Intl.Collator 实例复用:
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true })
list.sort((a, b) => collator.compare(a.name, b.name))
同样的 10 万条,这个写法 180 ms 左右,比裸 localeCompare 快 5 倍,还白送拼音排序和「第 2 章排在 第 10 章前面」的数字感知能力。
正确写法:单字段、多字段,以及一个我一直在推的兜底
// 数字和 Date 对象,直接减
list.sort((a, b) => a.createdAt - b.createdAt)
// 字符串不要用减法,会得到 NaN
list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
// ISO 8601 日期字符串可以直接比大小
list.sort((a, b) => (a.isoTime < b.isoTime ? -1 : a.isoTime > b.isoTime ? 1 : 0))
多字段排序没必要堆 if/else,用 || 串起来就行,因为 0 是 falsy:
list.sort((a, b) =>
(a.status - b.status) ||
(b.createdAt - a.createdAt) ||
a.id - b.id
)
最后那个 a.id - b.id 是我个人的习惯,加一个唯一键兜底,能保证比较函数是全序的,顺带把对稳定性的依赖也消掉了。这是我踩完上面那个坑之后一直在推的做法,有人觉得多余,我理解,但它确实让排序结果不再看引擎脸色,升级 Node 的时候能不慌。
如果排序的 key 需要计算,别放进比较函数
比较函数会被调用几万到几百万次,key 的计算只需要 n 次。这种写法有个名字叫 Schwartzian transform,中文一般叫装饰-排序-去装饰:
// 慢:keyOf 被调用 O(n log n) 次
list.sort((a, b) => keyOf(a) - keyOf(b))
// 快:keyOf 只被调用 n 次
list
.map(item => ({ item, key: keyOf(item) }))
.sort((a, b) => a.key - b.key)
.map(x => x.item)
我用 100 万条、key 需要一次 JSON.parse 的数据试过,第一种 4.2 秒,第二种 1.1 秒,差距接近 4 倍。代价是多一次 map 和一次对象分配,100 万条大概多占 40 到 60 MB 内存。服务端通常划算,内存敏感的移动端要掂量一下。
几个很少有人提的 sort 细节
sort 是原地排序,返回的是同一个数组引用。const sorted = arr.sort(...) 里 sorted 和 arr 是同一个东西,改一个另一个跟着变。要副本用 [...arr].sort(),或者用 ES2023 的 arr.toSorted()(Node 20+ / Chrome 110+,返回新数组,原数组不动)。
数组里的 undefined 和空洞一定会被排到末尾,比较函数对它们不生效。[3, undefined, 1].sort((a, b) => a - b) 得到 [1, 3, undefined],跟你比较函数写得对不对没关系。
不带比较函数时,sort 会把所有元素转成字符串再按 UTF-16 码元比较。所以 [10, 9, 1].sort() 是 [1, 10, 9],而 ['Z', 'a'].sort() 还是 ['Z', 'a'],因为 Z 的码元是 90,a 是 97。
比较函数不允许有副作用,规范也不保证它被调用多少次,甚至不保证一定被调用 n-1 次。我有一次在里面打 console.log 数调用次数,10 万个元素跑出 150 万行输出,直接把终端卡死了,那个终端我后来重开的。
最后,说一个不太受欢迎的结论
「sort 不稳定」这个说法被讨论得太多了,多到掩盖了真正的问题。TimSort 换掉 QuickSort 之后,Node 侧因为稳定性踩坑的案例我见得很少;因为比较函数写错踩坑的,今年就见了四次。稳定排序解决的是两个元素比较结果相等时要不要保持原顺序,而绝大多数线上排序错乱,根源是比较函数压根没返回合法的三态结果——这种时候稳定不稳定都救不了你。
我更想推的是另一件事:比较函数应该被当成一个契约来写,不是一个顺手敲出来的表达式。它有三个必须满足的性质,反对称(a 在 b 前则 b 不在 a 前)、传递(a 小于 b 且 b 小于 c 则 a 小于 c)、全序(任意两个元素都能比出结果)。违反任何一条,结果就是 implementation-defined,翻过来就是「你也别问,我也不保证」。
写测试的时候别只断言排完的第一个和最后一个,那是覆盖面最差的两个位置。跑一遍 result.every((item, i) => i === 0 || cmp(result[i - 1], item) <= 0),一行就能把上面这些坑全抓出来。我现在的做法是把它塞进单测的公共断言里,谁写新的排序逻辑都得过这一关。