先说个反直觉的结论
上周我在改一个后台的表格组件,8000 行数据,排序在前端本地做。切到按"创建时间"排的时候肉眼可见地卡了一下,用 Performance 面板录了一遍,一帧里 Scripting 占了 190ms 左右,其中真正落在比较环节的大概 160ms 出头。也就是说,排序调度本身的开销只占零头,大头全在我自己写的那几行比较函数里。
这事促使我把 V8 的 sort 翻了一遍。结论挺反直觉的:大部分人关于"sort 慢"的直觉方向是错的。你以为瓶颈在 O(n log n) 里的那个 log n,实际上瓶颈在每次比较内部你干了多少活。8000 个元素,比较次数大概十万次上下,你在比较函数里多塞一个 new Date(),就要多执行十万次 new Date()。这个乘法关系比算法选择重要得多,但几乎没人一开始会往这想。
V8 里到底走的是哪条路
我印象里 V8 在 7.0 之前用的是 QuickSort,而且是不稳定排序。2018 年 Chrome 70 那个版本前后换成了 TimSort,Array.prototype.sort 同时变成了稳定排序。这个"稳定"后来在 ES2019 被正式写进规范,所以现在你可以放心地认为 sort 是稳定的;但如果你要兼顾 2018 年以前的老浏览器,这个保证不成立,早年的 Safari 和旧版 V8 在某些输入下会把相等元素的相对顺序打乱。
TimSort 的思路不复杂:扫描数组,找已经有序的片段(run),如果 run 太短就用二分插入排序把它撑到一定长度,然后把这些 run 两两归并。归并时有个 galloping mode,如果某一方连续胜出若干次(V8 里这个常量是 7),就不再逐个比较,改用指数搜索快速跳过一整段。这是它在部分有序数据上明显快于朴素归并的原因,也是为什么对"基本有序、偶尔有变动"的列表做重排其实很便宜。
有个细节值得单独说:比较函数不一定会被调用 n log n 次。我拿 10000 长度数组测过,随机排列和完全倒序,比较次数都在 13 万上下;但如果只是把中间两个元素换了个位置,比较次数直接掉到一万出头。所以列表变动很小的时候,老老实实重新 sort 一遍完全没问题,不需要专门去写增量插入逻辑。
最贵的那个坑,其实就一行
下面这段是我最初的写法,看起来毫无毛病:
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
8000 行数据,同一台机器同一个 Chrome,这一句大概 170ms。改成先在 map 里把时间戳算出来、排序时只做减法:
list.sort((a, b) => b._ts - a._ts);
掉到 11ms 左右。差价不是算法的差价,是每比较一次就解析两个 ISO 时间字符串的差价。'2024-03-15T08:22:11.000Z' 这种带时区的格式,内部要走完整解析加时区偏移计算,单次在微秒量级,乘十万就是一百多毫秒。非常朴素的乘法。
不想往对象上挂私有字段的话,可以用装饰-排序-去装饰的写法:
const sorted = list
.map(item => [Date.parse(item.createdAt), item])
.sort((x, y) => x[0] - y[0])
.map(pair => pair[1]);
代价是多两趟数组遍历加一次 n 长度的数组分配。8000 行这两趟加起来 1ms 上下,跟省下的 150ms 完全不成比例。这里有个必须提醒的点:Date.parse 遇到非标准格式会返回 NaN,NaN 参与减法还是 NaN,而比较函数返回 NaN 在规范里按 0 处理,等于告诉引擎"这两个相等",最终位置取决于归并顺序,表现出来就是"位置随机且每次可能不一样"。上线前务必确认时间格式是标准的,或者加一层 isNaN 兜底。
中文字符串排序是另一个维度的坑
['张', '李', '王'].sort() 出来的是按 UTF-16 码元排的,不是拼音序,很多人直接上 localeCompare:
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
结果是对的,但每次比较都会隐式创建一个 Intl.Collator 实例。我实测同一个 8000 行数组,这种写法 240ms 左右。改成把 Collator 缓存到外面:
const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'base' });
list.sort((a, b) => collator.compare(a.name, b.name));
同样数据 26ms,差了将近 9 倍。
Intl.Collator 的选项值也值得注意。默认 sensitivity 是 'variant',大小写和音标都区分;做面向用户的名字或商品排序,一般用 'base',它会把 A 和 a、é 和 e 当作同一个。另一个是 numeric: true,加上之后"第 2 章"才会排在"第 10 章"前面,不加的话按字符逐位比较,"第 10 章"反而靠前。这两个选项我都踩过。
几个容易被忽略的细节
TypedArray 的 sort 走的是另一条路径。 它不传比较函数时默认按数值升序排,这点和普通数组按字符串排不一样,所以 new Int32Array([10, 2, 1]).sort() 得到的是 [1, 2, 10] 而不是 [1, 10, 2]。传了比较函数它也能用,但每次比较都要跨 JS 和底层实现来回,实测比不传慢一档以上,能用类型化数组的数值场景就别传回调。
toSorted() 是 ES2023 才有的。 Chrome 110、Node 20 开始支持,返回新数组不动原数组。在 React 里改 state 时比 [...arr].sort() 顺手,但内存开销是实打实的。8000 个对象只是引用复制,几十 KB 级别,可忽略;如果元素是字符串并且很长,就得算一算。
比较函数必须自洽。 写成 (a, b) => a.id > b.id 是经典错误,布尔值转成数字只有 1 和 0,永远不会有 -1,排序结果会乱得很有规律,而且不同引擎还不一样。V8 现在会对非法比较函数做一些检测,但检测本身有开销,也不保证一定抛错,别指望引擎帮你兜底。
几千个元素以内真不用管。 5000 个元素大约六万次比较,哪怕比较函数每次花 100ns,总共 6ms。值得优化的是十万量级以上,或者比较函数里确实在做重活(解析、正则、locale 转换)。提前优化这块基本是白费力气。
最后
我把那个表格改完之后顺手存了个 benchmark 脚本,跑 node --allow-natives-syntax 的时候还能顺手看看 %GetOptimizationStatus。不过说实话,光是排序这点事,真没必要去抠 TurboFan 有没有把比较函数内联——我用 --trace-opt 看过,那种只做一次减法的比较函数第一次跑基本就内联了。把比较函数里的重量级操作挪出去,收益是数量级的;把比较函数本身写得多漂亮,收益是零。这两件事的优先级,我花了两个下午才想明白。