先说个我自己的真实经历。有次帮同事排查一个列表排序问题数据是几组数字他写了arr.sort()结果排出来是[1, 10, 2, 22, 3]这种完全看不懂的顺序。他盯着屏幕看了半天第一反应是sort排序坏了。其实不是坏了是他把 sort 的默认行为当成纯数字排序了。这种误解在 JavaScript 里特别常见因为 sort 这个 API 太简洁了简洁到让人忽略它背后那套字符串化之后再比较的默认逻辑。这篇文章我就把这个 sort 排序原理彻底讲透包括默认排序的坑、比较函数到底在比什么、不同引擎底层用了什么算法、稳定排序带来的连锁影响以及一堆实际项目中常见的边界问题。不管你是刚入门的前端新手还是写了好几年业务代码的老手只要在博文中写过一行sort这篇文章都值得仔细看一遍。1. 从默认排序说起为什么 sort() 排数字会乱掉很多人第一次遇到 sort 反直觉的结果就是数字数组直接排。[10, 9, 8, 7, 6].sort()返回的居然是[10, 6, 7, 8, 9]。这直接让人怀疑人生。这里的根源在于sort() 在不传比较函数时会把数组中的每一个元素先显式转换成字符串然后按照字符串的 Unicode 码点顺序进行排序。所以数字10会先被转成字符串10数字6转成6字符串比较时一位一位比字符编码1的码点比6小因此10就排在6前面了。所以不是 sort 坏了而是它默认按字典序在排。String.prototype.charCodeAt 派上用场。你可以试试10.charCodeAt(0)和6.charCodeAt(0)一个是 49一个是 5449 54所以10排前面。只要想通这一层大部分 sort 排序结果不对的困惑都会消除。那么问题来了如果我想真正按数字大小排序怎么办答案是传一个比较函数作为参数const nums [10, 9, 8, 7, 6]; nums.sort((a, b) a - b); console.log(nums); // [6, 7, 8, 9, 10]把a - b的结果交给 sort升序就出来了。想降序就写b - a。但为什么返回负数就升序、返回正数就降序这背后的机制是第二层要搞清楚的东西。1.1 字符串排序也不只是字母顺序那么简单字符串排序相对好理解每个字符比较 Unicode 码点。但要注意几个容易忽略的点大写字母排在前面。A.charCodeAt(0)是 65a.charCodeAt(0)是 97所以sort()之后apple会排在Banana后面因为你先遇到的B的码点 66 小于a的 97。实际业务里如果对用户名做排序大小写混着排会非常费解。中文不是按拼音排的。中文字符的 Unicode 编码没有规律可循直接 sort 的结果看起来就是随机顺序。要做中文拼音排序得用localeCompare(zh-Hans-CN)或者Intl.Collator后面会专门说。数字字符串也不等于数字比较。100和20比字典序会认为100更小因为1比2小。这在处理表格里混着字符串形式的 id 时会踩坑。2. 比较函数到底在比什么返回值的正负零决定一切如果你读过 V8 或其他引擎的排序相关说明会发现比较函数的语义极其简单compareFn(a, b)返回一个数字排序算法根据这个数字决定 a 和 b 的相对顺序返回负数a 排在 b 前面返回 0a 和 b 的相对顺序保持不变返回正数b 排在 a 前面就这么三条规则没有更多了。所以a - b这个写法本质上是在利用减法产生的正负号来表达谁排在谁前面。2.1 为什么不要写return a b这种比较经常看到有人图省事写这样的代码arr.sort((a, b) a b);仔细想一下这个逻辑。如果a b为 true返回的是布尔值 true会被转成 1表示 b 应该排在 a 前面这等价于降序排列。如果a b为 false返回 false 转成 0表示 a 和 b 的相对顺序不变。看着好像也能用但问题出在a b的时候此时a b是 false返回值 0这不是大问题。真正的问题是当a b时你返回的也是 00 代表顺序保持不变并不代表b 排在 a 前面。这会导致在某些排序算法下升序排列变成部分有序、部分不动的半吊子状态。我在实际项目里见过因为这种写法导致表格排序时某些行纹丝不动刷新后又恢复正常。排查了很久才发现是比较函数返回了布尔值而不是数值。正确做法要么用return a - b要么用return a b ? -1 : a b ? 1 : 0一定要把三种情况都明确表达出来。2.2 对象数组排序按属性、按多字段真实业务里更多是对对象数组排序。比如一个商品列表按价格从低到高const products [ { name: A, price: 50 }, { name: B, price: 10 }, { name: C, price: 30 } ]; products.sort((x, y) x.price - y.price);多字段排序的常规做法是写两层判断。比如先按价格排价格相同再按销量排products.sort((x, y) { if (x.price ! y.price) { return x.price - y.price; } return x.sales - y.sales; });这里也可以写成x.price - y.price || x.sales - y.sales利用 0 的 falsy 特性串联条件。代码短可读性看团队风格。我个人更喜欢显式 if 返回因为后续加排序字段时不容易改错。3. 引擎底层的排序算法插入排序、快速排序与 TimSort聊聊 sort 的实现。ECMAScript 规范只规定 sort 要稳定排序但具体用哪种排序算法由各个 JavaScript 引擎自己决定。不同的引擎、不同的数组长度底层走的算法路径相差很大。V8 是目前最主流的生产环境引擎。它对待 sort 的方式经历了几个阶段。早期 V8 对小数组长度小于等于 10 的典型档位用插入排序对较大的数组用快速排序的变体。插入排序在数据量小时性能不错常数因子低适合 CPU 缓存友好调度。快速排序在数据量大时有平均 O(n log n) 的时间复杂度但它的缺点也很明显不稳定并且在某些特殊分布的输入下可能退化到 O(n^2)。后来 V8 改用了类似 TimSort 的混合排序算法。TimSort 的特点是对局部有序的数据非常友好能利用数据中天然存在的有序片段把整体复杂度压到接近 O(n)同时保证稳定性。这也是 Python 内置排序一直采用的算法。用一句话概括我常跟同事说的结论你不需要自己实现排序算法但你必须知道 sort 的稳定性是引擎承诺的不要再自己写一个快排去替代它。3.1 为什么小数组用插入排序反而是最优解很多人不理解插入排序明明是 O(n^2)为什么引擎不直接用快排因为时间复杂度分析的是数据规模趋向无穷时的趋势而实际处理 10 个元素的时候复杂度的常数因子、函数调用开销、缓存局部性比渐近复杂度更重要。插入排序对几乎有序的小数组特别快而且它原地操作不需要额外空间。正因为如此很多排序库在分治递归到子数组规模较小时都会手动切换到插入排序。V8 在早期就是这么干的今天的很多实现也保留了类似策略。3.2 快速排序的不稳定与性能退化快排是教科书的宠儿但放到生产环境做通用排序并不是十全十美。我记得早期版本 V8 的快排在处理一些精心构造的数据时会有明显的性能波动。后来换掉它的原因之一就是规范层面要求 sort 必须稳定。快排的 partition 过程天然不稳定除非做额外的索引绑定或额外空间但那又违背了原地排序的设计。所以如果你去看规范草案的历史ES2019 之后的规范明确要求Array.prototype.sort是稳定排序。这个改动直接推动了各大引擎迁移到稳定算法比如 V8 采用 TimSort 的思路其他引擎也用归并排序的思路。归并排序也是稳定的。3.3 不同环境下 sort 的细微差异虽然规范要求稳定但你在实际编码时仍然不应该假设所有环境都长一个样。老旧的浏览器、某些非主流运行时sort 的实现可能还是老快排。跨端开发时JavaScript 运行时可能是 JavaScriptCore、V8、SpiderMonkey 之外的某套实现稳定性表现要实际测过才知道。我的建议是如果业务逻辑依赖排序稳定性不要在代码里依赖运行时实现而是自己用辅助索引把稳定性做出来。比如拿一个对象数组排序可以在每个元素挂一个原始下标比较时先比业务字段字段相同比原始下标。这样即使引擎不稳定结果也是稳定的。4. 稳定排序的价值当业务真的需要分组后保持原顺序很多人觉得稳定排序是个学院派概念跟业务没关系。其实就我观察业务需求里先按时间倒序再按状态分组这种场景经常出现。比如一个审批列表用户希望先按状态把待办和非待办分开待办里再保持时间倒序。如果你两次排序之间第二次 sort 不稳定第一次排序产生的相对顺序就被打乱了。比如这样一段伪需求// 先按时间倒序排 list.sort((a, b) b.time - a.time); // 再按状态分组待办在前 list.sort((a, b) Number(b.pending) - Number(a.pending));如果第二次排序是稳定的那么同一组 pending 状态内的元素会保持第一次排序的顺序如果引擎不稳定用户会发现同状态内的顺序是乱的。今天的规范保证 sort 稳定所以这个写法是安全的。但为了未来可维护性以及照顾某些混合运行时很多人会改写成一次排序同时比较两个条件逻辑更明确也不依赖运行时的稳定承诺。list.sort((a, b) { const statusDiff Number(b.pending) - Number(a.pending); if (statusDiff ! 0) return statusDiff; return b.time - a.time; });我在项目里推荐的都是第二种能用一次比较函数说完的就不要在多次 sort 之间来回折腾。既省了一次完整排序的开销又彻底绕开了稳定性问题。5. 类型与边界陷阱null、undefined、混合类型、中文排序5.1 undefined 与 null 的诡异行为先说undefined。任何一个数组只要包含undefinedsort 之后这些值会被排到数组末尾而且比较函数根本不会接收到它们。规范明确说了undefined元素会被特殊处理统一放到末尾不参与比较。这个行为其实对业务是友好的——你不需要自己把它过滤掉它也不会破坏排序流程。null就不一样了。null会被转成字符串null参与普通比较。如果你对数字数组排序数组里有null那null转成 0参与数值比较时表现成 0。可一旦数组是字符串数组null又参与字典序位置居中。所以建议排序前先清洗数据。5.2 混合类型数组排序结果没法预测如果数组里混着数字、字符串、对象sort 会把它们都转成字符串再比结果就是一个完全无法用常规直觉理解的顺序。比如[1, 2, true, null]这种数组转成字符串就是1,2,true,null此时比较值已经和你头脑里的数据类型无关了。开发规范里碰到这种数据结构第一反应应该是检查数据来源而不是想着怎么排序。5.3 大小写敏感与中文排序的实用方案严格按 Unicode 码点排序时大小写会混排。比如[apple, Banana, cherry].sort()的结果是[Banana, apple, cherry]因为B的码点比a小。如果产品想要的是常规字母序就要用到localeCompare[apple, Banana, cherry].sort((a, b) a.localeCompare(b, en));中文排序是另一个高频问题。直接 sort 中文按码点排出来的顺序通常不符合拼音排序。localeCompare在中文环境里可以做到拼音排序[张三, 李四, 王五].sort((a, b) a.localeCompare(b, zh-Hans-CN));但要注意不同浏览器内核的 localeCompare 实现有差异某些环境下中文排序结果不完全一致。如果要做严格一致的拼音排序可以引入拼音转换库先把中文转成拼音再排序。代价是多一个依赖收益是跨端结果一致。读者可以根据项目对排序一致性的敏感度做选择。Intl.Collator是另一个选择性能一般比localeCompare好一些比较大量字符串时建议优先考虑const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); [张三, 李四, 王五].sort(collator.compare);注意sensitivity: base的效果是忽略大小写和变音符号如果你希望排序时大小写等价这个参数很有用。6. 排序的副作用原地修改、拷贝副本和比较函数性能6.1 sort 会修改原数组这一点太容易踩了。sort()是原地排序它直接修改原数组并返回同一个数组引用。这在函数式编程风格里特别危险因为你会不经意间污染上游数据。const original [3, 1, 2]; const sorted original.sort(); console.log(original sorted); // true console.log(original); // [1, 2, 3]已经被改了如果不想修改原数组先拷贝一份const sorted [...original].sort((a, b) a - b);注意[...original]是浅拷贝如果数组元素是对象排序中交换的是引用对象本身不会被拷贝。一般排序只交换元素位置所以只要不修改对象属性浅拷贝够用。另外把sort的结果直接赋给另一个变量也不会产生新数组它和原数组是同一个引用。我给新人的建议是凡是涉及 sort 的地方先问一句我能不能接受原数组被改变。能接受就直接用不能接受先展开拷贝。6.2 不要在比较函数里做重计算比较函数的调用次数比你想象的多。对长度为 n 的数组一次完整排序过程中比较函数会被调用 O(n log n) 次比如 10000 个元素可能调用十几万次比较函数。如果比较函数里有一段昂贵的计算比如实时解析日期字符串、读取嵌套对象的多个字段、做正则匹配整体性能会呈指数级恶化的直观感受。优化方法很简单预处理 空间换时间。先把数组元素转成包含排序键的对象排序完成后再把原始对象取出来const withKey arr.map((item, index) ({ item, key: parseTime(item.createTime), index })); withKey.sort((a, b) a.key - b.key || a.index - b.index); const result withKey.map(entry entry.item);这样把耗时解析从每个元素比较很多次变成每个元素只解析一次排序阶段只剩数值比较性能差距非常大。我在做后台管理系统的列表排序时经常用这招处理日期字段。6.3 处理大数据量时的本地排序替代方案如果数组有几万甚至十万条数据js 原生的sort通常还是够用的。但浏览器线程在做复杂排序时会占用主线程时间导致页面卡顿。遇到这种情况可以拆分到requestAnimationFrame里分块处理或者用 Web Worker 把排序放到后台线程。绝大多数管理后台场景不需要走到这一步但如果你的表格支持一键全量排序最好还是先做性能测试再下结论。7. 几个高频排序需求的开箱即用写法这部分整理我在真实项目里反复用到的一些 sort 写法可以直接抄走。按数字升序arr.sort((a, b) a - b);按数字降序arr.sort((a, b) b - a);按字符串本地字母序arr.sort((a, b) a.localeCompare(b, en));按对象日期字段降序arr.sort((a, b) new Date(b.createTime) - new Date(a.createTime));注意new Date在比较函数里创建对象是低效的数据量大时请用预处理方案。按布尔值排序true 在前arr.sort((a, b) Number(b.isActive) - Number(a.isActive));多字段排序arr.sort((a, b) { const diffByStatus Number(b.status) - Number(a.status); if (diffByStatus ! 0) return diffByStatus; return new Date(a.createTime) - new Date(b.createTime); });8. 排序扩展手写稳定排序与函数式数据管线的替代方案虽然引擎已经做得很好了但有些场景需要更强的控制力。比如你不想依赖引擎的稳定性、想彻底保证跨环境行为一致可以自己封装一个稳定的排序函数。最简单的做法是用归并排序的迭代版本纯数组操作背下来也不难。不过对于绝大多数应用我更建议优先用sort 辅助索引的方式把稳定问题转成比较函数里带上原始下标这一招。辅助索引稳定排序示例const arr [ { group: B, score: 80 }, { group: A, score: 90 }, { group: B, score: 70 } ]; const withIndex arr.map((item, index) ({ item, index })); withIndex.sort((x, y) { if (x.item.group ! y.item.group) { return x.item.group.localeCompare(y.item.group); } return x.index - y.index; // 保证同 group 内维持原顺序 });如果你在使用函数式工具库比如 lodash 的orderBy它会包装一套稳定的排序逻辑但底层仍然调原生 sort。理解 sort 的原理能帮你判断某些库在处理复杂排序时为什么会多排几次以及为什么你的自定义比较函数在库的封装下表现不如预期。最后说一句我的体会。每次看到 sort 相关 bug十有八九不是算法问题而是没想清楚比较函数的返回值语义或者没意识到默认字符串排序。你只要把这两件事在脑子里焊死sort 就是整个 JavaScript 里最不值得害怕的内置方法之一。以后在代码评审里看到sort()不带参数你可能会比我更先皱起眉头。