资讯动态

JavaScript sort()排序详解:默认规则、比较函数与稳定性陷阱

发布时间:2026/10/14 18:22:45 来源:尧图企业网站定制
1. sort排序的基础用法与默认规则深挖1.1 一行最简单的sort背后藏着什么先抛个最常见的场景你拿到一个数字数组[10, 2, 1]随手写了一句arr.sort()结果返回的却是[1, 10, 2]。很多新手当场就懵了——说好的从小到大呢这事我至少被问过五次。问题就出在Array.prototype.sort()的默认行为上它会先把数组里的每个元素强制转换成字符串再按 UTF-16 码元code unit逐个比较字符。换句话说你以为在比数字大小实际上引擎在比字符串的顺序。拿10和2这对冤家来说10的第一位是字符1其码元值是 492的码元值是 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 码元 1221.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针对返回值有三种约定返回值小于 0a排在b前面返回值大于 0b排在a前面返回值等于 0a 和 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(, ));如果环境是稳定排序你会看到14, 22, 25, 31, 33, 50——两个 2 的原始位置 2 和 5排序后依然按 2、5 的顺序出现两个 3 也是 1 在 3 前面。如果结果出现了25, 22这种逆序那这个环境就是不稳定的。提示在绝大多数现代浏览器和 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()做什么都要记住它是“比较驱动”的。整个排序过程中的行为都取决于你的比较函数每一次返回的正负和零。把比较函数写对、写纯、写快后面的一切就都顺了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑