1. sort排序的基础用法与默认规则深挖
1.1 一行最简单的sort,背后藏着什么
先抛个最常见的场景:你拿到一个数字数组[10, 2, 1],随手写了一句arr.sort(),结果返回的却是[1, 10, 2]。很多新手当场就懵了——说好的从小到大呢?这事我至少被问过五次。
问题就出在Array.prototype.sort()的默认行为上:它会先把数组里的每个元素强制转换成字符串,再按 UTF-16 码元(code unit)逐个比较字符。换句话说,你以为在比数字大小,实际上引擎在比字符串的顺序。拿"10"和"2"这对冤家来说:"10"的第一位是字符"1",其码元值是 49;"2"的码元值是 50。49 小于 50,所以"10"就排到了"2"前面。这就是为什么你会看到[1, 10, 2]这种“看起来不对”的结果。
我知道你心里在想什么:那我不传参数的时候,到底谁会用默认排序?说实话,实际开发里默认排序确实很少直接用,但它帮我们理解了一件非常核心的事——排序比较的本质是“两个值之间如何确定谁先谁后”。你搞懂了这一点,后面写比较函数、处理对象数组、甚至排查线上排序 bug 的时候,思路都会清楚很多。
顺便提醒一句:sort()不只是返回排好序的新数组,它还会原地修改原数组。也就是说,你调完arr.sort()之后,arr本身也变了。稍不注意,后面再用到这个数组时,拿到的顺序已经不是你最初定义的那个了。
1.2 字符串比较到底比的是什么
既然默认排序是“把所有元素转成字符串再比”,那这里面就藏着一个精细的规则串。很多教程一句话带过,但我建议你还是把它刻在脑子里,因为线上很多诡异的排序结果,根源都在这:
- 比较是逐字符进行的。先比两个字符串的第一位,相同再看第二位,以此类推,直到分出大小或其中一个字符串耗尽。
- 比较的是码元值,不是视觉上的字母顺序,也不是拼音顺序。字符编码表里什么字符排在哪,就按那个位次来。
- 大写字母(A-Z 对应码元 65-90)整体排在小写字母(a-z 对应码元 97-122)之前。所以
"apple"和"Banana"比,"Banana"会排前面,因为"B"的 66 小于"a"的 97。类似的问题在英文排序里非常常见。 - 中文按 Unicode 码点排,不按拼音。比如
"中"、"文"、"排"、"序"这几个字,默认排序结果跟汉语拼音一点关系都没有。 - 带音调或特殊字符的字符串,位置往往出乎意料。例如
"é"(码元 233)会排在"z"(码元 122)之后,因为 233 比 122 大。
我整理了一张小表,方便你直接对照:
| 比较对象 | 默认 sort 结果 | 原因 |
|---|---|---|
[10, 2, 1] | [1, 10, 2] | 字符串"1"的码元 49 <"2"的 50 |
["apple", "Banana"] | ["Banana", "apple"] | 大写 B 码元 66 < 小写 a 码元 97 |
["中", "文", "排", "序"] | 按 Unicode 码点排 | 不按拼音 |
["é", "z"] | ["z", "é"] | é 码元 233 > z 码元 122 |
1.3 sort是原地排序,原数组会被修改
这一点我必须单独拿出来讲,因为我见过太多人在生产环境里栽在这里。sort()是原地排序(in-place),意思是它直接操作原数组,返回的也是同一个数组的引用。看下面这段代码:
const arr = [3, 1, 2]; const sorted = arr.sort(); console.log(sorted); // [1, 2, 3] console.log(arr); // [1, 2, 3] —— 原数组也被改了! console.log(sorted === arr); // true很多业务逻辑是先展示数组默认顺序,用户点了一下表头排序,结果发现原有的数据顺序再也恢复不了了——因为原数组已经被 sort 改掉了。这是非常典型的“线上事故”。
解决办法很简单:排序前先做一次浅拷贝。
const sorted = [...arr].sort((a, b) => a - b); // 或者 const sorted = arr.slice().sort((a, b) => a - b);这两个写法效果一样,arr保持原样,sorted才是排好序的副本。我个人的习惯是:只要原数组后面还会被其他逻辑使用,一律先拷贝再排序,宁多不省。
提示:如果数组元素是对象,浅拷贝只拷贝了引用,排序会改变元素在数组中的顺序,但不会改变对象本身的属性,这个不用太担心。
2. 比较函数:sort的真正核心参数
2.1 返回值的正确语义
聊完默认规则,来看真正决定排序结果的东西——比较函数(compare function)。sort()接受一个可选参数,这个参数决定了“a 和 b 谁该排在前面”。它接收两个参数a和b,针对返回值有三种约定:
- 返回值小于 0:
a排在b前面 - 返回值大于 0:
b排在a前面 - 返回值等于 0:a 和 b 的相对位置不变(前提是排序算法是稳定的,这个后面详细讲)
我经常用一个生活化的类比来解释这件事。想象你面前有两个体重未知的人,排序就是让他们按体重从小到大站队。比较函数就是那台“秤”:它告诉你 a 比 b 轻还是重。返回负数代表“a 更轻,往左站”;返回正数代表“a 更重,往右站”;返回 0 就是“一样重,现在的位置别动”。
如果你写的是:
arr.sort((a, b) => a - b);这就是升序,小的在前。反过来:
arr.sort((a, b) => b - a);就是降序,大的在前。你需要明确的是:比较函数返回的是正负的“方向”,不是让你自己决定谁大谁小,而是告诉引擎你的判断结果。有个常见误区是有人写成:
arr.sort((a, b) => { return a > b ? true : false; });这个写法返回的是布尔值true或false,换算成数字就是 1 或 0。它只表达了“a 是否比 b 大”,没有表达“a 比 b 小”的情况——当 a 小于 b 时返回 0,这会告诉引擎“两者相等,位置不变”,排序自然就乱了。正确的做法永远是返回a - b这种正负分明的数值,哪怕你封装了复杂逻辑,也要保证最终返回值落在负数、正数、0 这三类里。
2.2 升序降序与各种数据类型排序
实际开发里,待排序的往往不是单纯的数字数组,而是对象数组或其他结构。我在这里给你一份“拿来即用”的常见写法清单。
数字数组升序 / 降序
[3, 1, 10, 2].sort((a, b) => a - b); // [1, 2, 3, 10] [3, 1, 10, 2].sort((a, b) => b - a); // [10, 3, 2, 1]对象数组按某个数字属性排序
比如一个排行榜数据:
const players = [ { name: 'A', score: 88 }, { name: 'B', score: 95 }, { name: 'C', score: 82 } ]; players.sort((a, b) => b.score - a.score); // 按 score 从高到低排对象数组按多个条件排序
先按 score 降序,score 相同时按 time 升序:
players.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; return a.time - b.time; });字符串按字母顺序(忽略大小写)
['apple', 'Banana', 'cherry'].sort((a, b) => { const x = a.toLowerCase(); const y = b.toLowerCase(); return x < y ? -1 : x > y ? 1 : 0; });字符串按拼音排序(中文场景)
['张三', '李四', '王五'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN') );localeCompare是一个专门用于字符串比较的方法,它对中文的拼音规则支持很好。注意第二个参数是指定语言区域,中文一般传'zh-Hans-CN',表示简体中文。不同运行环境下默认区域可能不同,显式指定更稳妥。
日期字符串排序
如果是"2024-01-15"这种 ISO 格式,直接a.localeCompare(b)或转成时间戳比较都行;但如果是"2024/1/15"这种非规范格式,建议先转时间戳再比,否则字符串逐字符比较很容易出错。
2.3 常见的错误写法与极端情况
写比较函数最容易翻车的有这么几类,都是我在实际代码和做技术咨询时遇到过的高频问题。
第一:混合类型数组
[1, '10', 2, '3'].sort(); // ["1", "10", "2", "3"]什么都没传时,所有元素都转字符串,数字和字符串混在一起的结果非常反直觉。即使传了比较函数:
[1, '10', 2, '3'].sort((a, b) => a - b); // [1, 2, "3", "10"]这个例子中'10'和'3'会被隐式转成数字参与比较,结果看似“正常”,但不同元素的原始类型已经被搞混了。强烈建议:排序之前先统一数据的类型,不要在比较函数里依赖隐式类型转换。
第二:比较函数内部有副作用
比如有人在比较函数里修改了原数组的某个字段,或者在比较函数里做了 console.log。比较函数在排序过程中会被调用很多次(后面讲算法时会说到具体次数),任何副作用都会被放大,而且可能直接影响排序结果的正确性。比较函数应当是纯函数:接收两个值,返回一个数,不做任何其他事。
第三:大面积数组里比较函数消耗过大
如果比较函数里做了大量计算,比如每次比较都解析日期、正则匹配、甚至发起网络请求,数组一长,性能会雪崩。我之前优化过一个线上表格,排序 2000 行数据要卡两三秒,后来发现比较函数里每次都new Date(str).getTime(),改用预计算后速度立马上来了。优先把需要比较的值提前算好放进缓存字段,比较函数只做最简单的减法或localeCompare。
3. 引擎内部的算法选择与稳定性逻辑
3.1 不同长度数组会用不同算法
很多人以为sort()就是“一种排序算法”,实际上主流 JS 引擎在实现sort()时,会根据数组长度和类型选择不同的策略。这个细节在规范文档里没有硬性规定,属于各自的实现,但思路是大致相似的。
以某个主流引擎为例,它的策略大致是:
- 数组长度较短时(常见的阈值是 10 个元素左右),使用插入排序(Insertion Sort);
- 数组长度较长时,早期版本使用快速排序(Quick Sort),近些年的新实现逐渐改成了TimSort(一种稳定的归并排序变体)或类似算法。
为什么短数组不直接用快排?道理很简单:快排是递归的,递归有函数调用开销,分区也有额外逻辑;插入排序虽然时间复杂度最坏是 O(n²),但它没有递归、代码路径短、常数极小。对于十几个元素的数组,插入排序的实际执行时间往往比快排还短。这就好比你去隔壁邻居家送个文件,走路就行,没必要发动汽车——虽然汽车最高时速快,但启动、停车、锁车这些开销反而让短途变得更慢。
长数组用快排,是因为 O(n log n) 的平均复杂度在大数据量下的优势非常明显。注意是“平均”,快排的最坏情况是 O(n²),所以工程实现里都会做“防止最坏情况”的处理,比如随机选择基准(pivot)、三点取中等手段。
3.2 从快速排序到归并排序:为什么稳定性成为关键
当你给sort()传入一个比较函数时,还有一个长期被忽略的属性叫做稳定性(stability)。稳定排序的意思是:当两个元素在比较函数看来“相等”(返回 0)时,它们在排序后的数组中保持原来的相对顺序。
为什么要关心这个?举一个非常常见的需求:表格先按“组别”排序,再按“分数”排序。如果排序算法不稳定,第二步排序时可能会把第一步中同分数但不同组的相对位置打乱,导致“组别”这个大分组错乱。稳定排序则能保证:第二步排序只调整分数不同的元素,分数相同的元素还保持着第一步排好的分组顺序。
ES 规范在 2019 年之前,并没有强制要求Array.prototype.sort()必须是稳定的。2019 年发布的第十版规范(ES2019 / ECMAScript 2019)明确规定了sort()必须稳定,这也推动了各引擎的实现统一。现在你用的主流 JS 环境,sort()基本都是稳定的。
TimSort 之所以被选中,核心原因之一就是它天然稳定。它的思路是扫描数组,找出已经天然有序的子序列(称为 run),然后在栈上对这些 run 进行归并。这个过程中,相等元素的相对顺序不会被破坏,所以稳定。TimSort 的平均和最坏复杂度都是 O(n log n),而在处理“已经基本有序”的数据时,它能达到接近 O(n) 的性能,对真实业务数据(经常是部分有序的)非常友好。
3.3 排序稳定性怎么测
你知道自己的运行环境是否稳定,其实一件小事就能测出来。给每个元素带一个“原始序号”,按值排序后,看看值相同的元素序号是否还保持上升。
const arr = [5, 3, 2, 3, 1, 2]; const withIndex = arr.map((value, index) => ({ value, index })); withIndex.sort((a, b) => a.value - b.value); console.log(withIndex.map(item => `${item.value}@${item.index}`).join(', '));如果环境是稳定排序,你会看到1@4, 2@2, 2@5, 3@1, 3@3, 5@0——两个 2 的原始位置 2 和 5,排序后依然按 2、5 的顺序出现;两个 3 也是 1 在 3 前面。如果结果出现了2@5, 2@2这种逆序,那这个环境就是不稳定的。
提示:在绝大多数现代浏览器和 JS 运行时里,上面的输出已经是稳定顺序了。但如果你处理的是非常老旧的运行环境,或者你自己实现排序算法时,稳定性依然值得多看一眼。
关于稳定性,还有一个工程上的关联点:稳定的算法让“多字段排序”成为可能。你完全可以把多个排序条件拆成多次sort()调用,从次要条件开始逐层往主条件走。每一步都稳定,就不会破坏前面步骤已经排好的顺序。这也是很多表格控件实现“依次点击表头多列排序”的底层原理。
4. 从手写排序到理解sort:排序算法演对照
4.1 冒泡与插入:短数组时代的“笨办法”
理解sort()的内部逻辑,最好的方式就是自己动手写一遍基础排序算法。我们先从两个“朴素”的算法说起。
冒泡排序
每次比较相邻两个元素,如果顺序不对就交换,一趟下来最大(或最小)的元素冒到了末尾。代码长这样:
function bubbleSort(arr) { const len = arr.length; for (let i = 0; i < len - 1; i++) { for (let j = 0; j < len - 1 - i; j++) { if (arr[j] > arr[j + 1]) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; } } } return arr; }冒泡的复杂度稳定在 O(n²),交换次数又多,实际场景基本不会用。但它教学意义强,因为“相邻交换”的概念是所有排序算法里最好理解的。
插入排序
思路是:把数组分成“已排序区”和“未排序区”,每次从未排序区拿一个元素,插到已排序区的合适位置。模拟扑克牌摸牌理牌的过程,非常直观。
function insertionSort(arr) { for (let i = 1; i < arr.length; i++) { const current = arr[i]; let j = i - 1; while (j >= 0 && arr[j] > current) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = current; } return arr; }插入排序在“数据基本有序”时效率极高,几乎接近 O(n)。所以很多引擎的混合策略里,在一段数据量很小或者局部已经有序时,都会优先选它。这也是为什么短数组用插入排序而不是更“高级”的算法——不是不懂快排,而是在小数据规模上,工程性价比最高。
4.2 归并与快排:长短取舍的工程实践
当数据量变大,插入排序的 O(n²) 就无法接受了,得上分治法的两员大将:归并排序和快速排序。
归并排序
把数组不断对半拆,拆到只剩一个元素(天然有序),再两两合并有序数组。
function mergeSort(arr) { if (arr.length <= 1) return arr; const mid = Math.floor(arr.length / 2); const left = mergeSort(arr.slice(0, mid)); const right = mergeSort(arr.slice(mid)); const result = []; let i = 0, j = 0; while (i < left.length && j < right.length) { result.push(left[i] <= right[j] ? left[i++] : right[j++]); } return result.concat(left.slice(i)).concat(right.slice(j)); }归并排序的优点非常明确:稳定、最坏也是 O(n log n)。缺点是它需要额外的 O(n) 内存来存放合并结果,而且频繁创建新数组在 JS 里会产生不小的 GC(垃圾回收)压力。你可以在每次递归里用一块预分配的临时数组来缓解,但代码复杂度会上升。
快速排序
选一个基准值,把比它小的放左边、大的放右边,再递归处理左右两部分。
function quickSort(arr) { if (arr.length <= 1) return arr; const pivot = arr[Math.floor(arr.length / 2)]; const left = []; const right = []; for (let i = 0; i < arr.length; i++) { if (i === Math.floor(arr.length / 2)) continue; if (arr[i] < pivot) left.push(arr[i]); else right.push(arr[i]); } return [...quickSort(left), pivot, ...quickSort(right)]; }这段代码是教学版,空间效率很差,每次递归都新建数组。工程版快排是原地分区(in-place partition),不需要额外数组。快排的优势是平均性能非常好、缓存友好;弱点是不稳定,而且如果每次基准都选得不好,退化成 O(n²)。
4.3 手写排序与原生sort的实测对比
你可能会想:既然我能手写归并或快排,是不是某些场景下可以替代原生sort()?我的建议是:绝大多数情况下,直接用原生sort()就好。原因很简单,引擎里的排序实现经过了反复优化,对 CPU 缓存、比较函数调用方式、分支预测都有细致考量,普通手写代码很难全面超过它。
我不是光说结论,也给过自己跑过的实测数据。用 10 万个随机整数排序,分别调用原生sort((a, b) => a - b)、上面的归并排序、上面的快速排序。结论是原生 sort 通常比手写版本快,而且代码量最少、出 bug 概率最低。就算是用原地优化的快排版本,也未必能在所有数据分布上稳赢原生实现。
手写排序的真正价值有两个:一是学习价值,让你理解不同算法的时间、空间特性,看到别人代码里的排序时能快速判断出意图;二是特殊场景,比如你需要稳定排序但运行环境不支持、或者面对的是一种完全自定义的数据结构,这时自己实现才必要。理解归并、快排的代码,也让你能看懂引擎内部“为什么这样选”的考量,这是解题思路层面的提升。
5. 实战场景与常见问题排查速查
5.1 典型应用场景
排行榜:先分数降序,再时间升序
排行榜是排序需求里最“经典”的之一。规则通常是分数高的排前面,分数相同则先到者排前面。
ranking.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; return a.timestamp - b.timestamp; });注意第二层逻辑:timestamp越小表示越早,升序就是“先到先得”,所以是a.timestamp - b.timestamp,不能写反。
表格点击表头排序
前端表格组件几乎必做“点击表头切换升降序”。这里有一个隐藏坑:第一次点击通常是升序,第二次点击是降序,第三次点击又回到升序,这个方向状态要单独维护。排序时记得先拷贝数据再排序,否则点击两次之后原列表顺序就丢了。
let isAsc = true; function handleSort(key) { const sorted = [...data].sort((a, b) => { const x = typeof a[key] === 'string' ? a[key].localeCompare(b[key], 'zh-Hans-CN') : a[key] - b[key]; return isAsc ? x : -x; }); isAsc = !isAsc; render(sorted); }上面这段是简化逻辑,实际业务里还要注意null/undefined值处理,建议把空值统一排到末尾,不要让localeCompare或者减法对undefined产生意外结果。
数组乱序(洗牌)
有一个非常常见的错误:用arr.sort(() => Math.random() - 0.5)来打乱数组。这个做法的问题有两个。第一,排序算法对比较函数的一致性有隐含要求,返回随机数等于告诉引擎“a 和 b 的大小关系每次都不一样”,结果不可控且某些引擎下会退化得很慢。第二,它不是均匀洗牌,大量元素会停在靠近原位置附近,不是真正的随机分布。正确的洗牌方案是 Fisher-Yates 算法:
function shuffle(arr) { const result = [...arr]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; }如果你想要的是“看起来乱一点”,sort(() => Math.random() - 0.5)能凑合;但如果你需要公平的随机抽取、抽奖、AB 测试分组,那就一定要用 Fisher-Yates。
5.2 常见问题速查表
我在下面整理了一份速查表,基本覆盖了我平时在项目里遇到过的 sort 相关高频问题。
| 问题现象 | 原因 | 对策 |
|---|---|---|
数字数组排序结果不对([10, 2]排成[10, 2]) | 默认转字符串按码元排序 | 传(a, b) => a - b |
| 原数组被意外修改 | sort()原地排序 | 先[...arr].sort()或arr.slice().sort() |
| 英文大小写混排乱序 | 码元值大写全部排在小写前 | 比较前统一toLowerCase() |
| 中文不按拼音排 | 引擎默认按 Unicode 码点比较 | 用localeCompare(b, 'zh-Hans-CN') |
| 数字与字符串混合数组结果怪异 | 隐式类型转换或默认转字符串 | 排序前统一元素类型 |
| 多条件排序结果不对 | 可能用了不稳定算法或条件顺序写反 | 先排次要条件,后排主要条件;确认环境稳定 |
| 打乱数组用 sort 导致随机性差 | Math.random返回 0.5 附近偏差大 | 改 Fisher-Yates 洗牌 |
| 大数据量排序卡顿 | 比较函数内做了重计算 | 预计算比较字段到缓存属性 |
5.3 避坑经验与排查技巧
我在实际排错的时候,有一套固定的检查顺序,基本能解决大部分 sort 相关 bug,分享给你参考。
排查顺序一:先看原数组还是副本
如果你发现排序后列表数据错乱,第一步先问自己:这里处理的是原数组还是拷贝后的数组?很多排序 bug 的根源都不是排序逻辑本身,而是原数组被提前污染了。排查时可以打点日志,在排序前打印JSON.stringify(arr),排序后再打一次,对比一下。
排查顺序二:确认比较函数的方向
a - b是升序,b - a是降序。这个方向在代码里很容易被某个封装逻辑掩盖掉。我见过有人封装了一个compareNumber(key, direction)函数,传参传反了,整个表格排序方向全反。排查这类问题,建议用一组已知顺序的小数组测试,比如[3, 1, 2],如果排出来的不是[1, 2, 3],那方向大概率反了。
排查顺序三:比较函数保持“全序关系”
比较函数有一个隐含的数学要求:它必须定义一种“全序关系”。简单说就是对于任意 a、b、c,如果 a 比 b 大、b 比 c 大,那么 a 一定要比 c 大,不能出现“石头剪刀布”式循环比较。某些算法(比如快速排序)在比较函数违反这种一致性时,结果就会非常诡异,甚至死循环。这也是为什么我反复强调不要在比较函数里做随机、副作用、不稳定的计算。
排查顺序四:微信或浏览器环境差异
不同 JS 运行环境对中文排序、稳定性的支持不完全一样。特别是localeCompare在不同操作系统下的结果可能会有细微差别(比如部分环境对声调的处理)。如果你的产品面向多平台,建议专门写一个排序测试用例,在目标环境全部跑一遍再上线。
最后再分享一个排查小技巧:遇到排序 bug 时,不要直接在几千行数据里查。先把数据缩小到一个能人工验证的规模(比如 5 到 10 个元素),再跑同样的排序逻辑,立刻就能看清比较函数的输入输出是否符合预期。这是我排查线上问题最常用的一招,效率极高。
注意:无论你用
sort()做什么,都要记住它是“比较驱动”的。整个排序过程中的行为,都取决于你的比较函数每一次返回的正负和零。把比较函数写对、写纯、写快,后面的一切就都顺了。