
排序这件事太熟了,熟到不会多看第二眼。一段数组交给 sort(),输出看着整齐,实际顺序是错的,而且不报错。下面五种写法都在本机 Node v26.8.1 上跑过,脚本存档 tools/verify-array-sort.mjs,贴出来的输出是从脚本日志里直接复制的。
写法一:不传比较器
原数组 [10,9,1,100,25]
[...nums].sort() [1,10,100,25,9]
[...nums].sort((a, b) => a - b) [1,9,10,25,100]
默认比较器先把每个元素转成字符串,再按 UTF-16 码元逐位比。字符串「100」的第二位是 0,排在「25」的 2 前面,于是 100 挤到了 25 前头。数字数组必须显式给比较器,这一条没有例外。
写法二:比较器里的字段名敲错
比较器是写了的,只是字段名错了一个字母,排序结果就几乎不动了:
rows.sort((a, b) => a.nmae - b.nmae)
原顺序 ["charlie","alpha","bravo"]
undefined - undefined = NaN
字段名敲错后 sort ["charlie","alpha","bravo"]
字符串相减再排序 ["charlie","alpha","bravo"]
undefined 减 undefined 得到 NaN,而 NaN 在排序里会被当成 0 处理,三个元素顺序一个没动。这类错最难查:不抛异常,数组长度对、元素类型对,只有顺序是错的,扫代码的时候眼睛会直接跳过那个拼错的字段名。要让它现形,得在比较器里加一步取值校验,取不到值就把整个排序打断,别让它安静地返回一个 NaN。
写法三:对象数组直接 sort()
String(rows[0]) "[object Object]"
rows.slice().sort() 之后 ["charlie","alpha","bravo"]
对象数组走默认比较器,先 String() 一遍,每个元素都变成 [object Object],彼此相等,于是顺序原样保留。这段代码不报错,只是什么都不做。对象数组的排序,比较器不是可选项。
写法四:以为 sort() 返回新数组
returned === original: true
original 被改成 [1,2,3]
sort() 返回的就是原数组本身,并且已经就地改好了。写 const next = list.sort() 的人,通常以为自己拿到的是副本,结果 list 已经被动过。数组来自 props 或者接口响应时,这个动作会改掉上游那份数据,等到界面上别的地方跟着变,问题已经离排序很远了。要副本就用扩展运算符,或者用 toSorted():
[].toSorted 存在: true
[...base].sort((a, b) => a - b) [1,2,3]
base.toSorted((a, b) => a - b) [1,2,3]
base [3,1,2]
写法五:中文和带数字的字符串
默认 sort() ["1","10","9","第10章","第1章","第2章","苹果","苹果10","苹果2"]
localeCompare(numeric) ["1","9","10","第1章","第2章","第10章","苹果","苹果2","苹果10"]
默认那条里第 10 章排在第 1 章后面,因为一位一位比码元。localeCompare 带上 numeric 选项之后,字符串里的数字段按数值比较,章节顺序正常了,苹果 2 也排在苹果 10 前面。中文的字序由 locale 决定,这一组传的是 zh-Hans-CN。
把上面这些合起来
比较器一律显式写,数字用减法,字符串交给 Intl.Collator:
const collator = new Intl.Collator("zh-Hans-CN", { numeric: true });
const sorted = [...items].sort((a, b) => collator.compare(a.name, b.name));
省下来的不只是代码量。同一个两万元素的数组,比较函数里每次调 localeCompare,排序耗时 700.6 ms:
比较函数里调 localeCompare: 700.6 ms
每次 new Intl.Collator: 28.7 ms
复用同一个 Collator: 28.5 ms
这里有个反直觉的点:每次 new Intl.Collator 和全局复用一个,耗时几乎一样(28.7 对 28.5)。说明开销不在构造比较器,而在 localeCompare 这个调用本身,它内部每次都要重新准备一套语言环境。所以要改的是把 localeCompare 换成 collator.compare,至于 Collator 全局复用还是现造,差别小到可以忽略。
顺带说一句稳定性
排序前 id 是 1 到 25,按 key 排完之后,同一个 key 下面的 id 仍然保持升序:
排序前 id [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,...]
排序后 id [3,6,9,12,15,18,21,24,1,4,7,10,...]
同 key 的 id 是否保持升序: true
现代 V8 用的是稳定排序,所以「同分元素顺序不保证」这条老提醒在现在的引擎上不成立。V8 换成稳定排序是 7.0 版本的事,更早的引擎上确实不稳定,这一点我没法在手上复现,属于查资料得来的结论,不算实测。真要碰到排序顺序在不同环境里对不上,先看引擎版本,再看比较器有没有返回 NaN。
什么时候不用这套
数组只有几个元素,怎么排都对,写清楚比写快重要。值得盯住的是两种场景:数组来自 props 或者接口响应,排前先复制一份;列表会被反复重排(表格点列头、搜索联想),比较器提到组件外面复用一次。至于那种「排序顺序看着对、明细对不上」的毛病,基本都是比较器返回了 NaN。