资讯动态

深入拆解JavaScript数组sort排序原理与常见坑

发布时间:2026/10/14 18:22:45 来源:尧图企业网站定制
如果你在控制台跑过[10, 9, 100].sort()大概率见过一个让你怀疑人生的结果[10, 100, 9]。这不是 JS 有病而是sort的默认规则跟大多数人脑子里“按数字大小排”的预期完全不是一回事。今天这篇内容我打算把 JavaScript 的Array.prototype.sort排序原理从外到内完整拆一遍默认排序到底在干什么、比较函数怎么写才是对的、引擎底层用的是什么算法、为什么 ES2019 之后排序必须稳定以及我在实际项目里踩过的几个跟排序相关的坑。不管你是刚接触前端的新手还是已经写过不少业务代码的老手只要代码里出现过.sort()这篇内容都很适合拿来复盘。1. 先搞懂默认排序为什么 [10, 9, 100] 会排成 [10, 100, 9]1.1 默认排序的“字符串规则”会让你措手不及先看一段再简单不过的代码const nums [10, 9, 100]; console.log(nums.sort());如果你期望的是[9, 10, 100]那现实会给你一记耳光控制台输出的是[10, 100, 9]而且nums数组本身也被原地改成了这个顺序。原因是Array.prototype.sort在不传比较函数时并不是按“数字大小”排序而是先把数组里的每个元素转换成字符串然后再按字符串的 Unicode 编码点顺序来排。数字10变成字符串10数字100变成100数字9变成9。字符串比较是逐字符比较编码位置所以10和100前面都是1、0长度更短的10会排在前面而100的第一个字符是19的第一个字符是91的 UTF-16 编码值比9小所以100又排到了9前面。最终就出现了[10, 100, 9]这个反直觉的结果。这不是浏览器特定的 bug而是 ECMAScript 规范明确规定的默认行为。任何现代 JS 引擎都会这么干。因此只要你想对数字数组做“从小到大的数值排序”就必须显式传入比较函数这个习惯从一开始就要记住。1.2 字符串逐位比较的细节不是看长度而是看第一个不同的字符很多人对字符串排序的认知停留在“按字母顺序”但 JS 里默认的字符串比较更底层从第一个字符开始逐个比较两个字符的 UTF-16 编码值一旦发现不同就立刻根据这个差值决定顺序后面的字符不再看了。举个例子[banana, Banana, apple].sort();结果不是[Banana, apple, banana]就是[Banana, banana, apple]这类顺序关键在于大写字母的编码值在小写字母前面。B的编码值是 66a的编码值是 97所以Banana整体会排在banana前面。也就是说默认排序里大小写混排的字符串顺序跟你平时翻字典的感觉可能并不一样。还有一个容易忽略的细节字符串比较不是比较“谁更长谁在前”而是逐字符比较只有当前缀完全相同、其中一个字符串先结束时短的那个才排在前面。比如ab和abx前两个字符一样ab更短所以它排在前面。这个机制在排文件名、版本号、编号这类数据时经常遭遇如果你希望“第2章”排在“第10章”前面默认排序会给你截然相反的结果必须借助额外选项来处理。1.3 undefined、null、Symbol 在排序中的特殊地位默认排序里undefined是被特殊对待的所有undefined元素会被直接移动到数组末尾而且不会参与普通元素的比较。这一点在自定义比较函数里也一样undefined通常不会作为参数传入比较器引擎会在比较逻辑之外单独处理它们。null就不同了。默认排序里null会被转成字符串null参与排序位置取决于null和其他字符串的比较结果。如果你传数值比较器(a, b) a - bnull会被转成数字0于是它可能排在所有负数后面、正数前面。还有一个极端情况如果数组里有Symbol元素默认排序会直接抛出TypeError。因为Symbol无法被隐式转换为字符串规范在排序准备阶段就会挂掉。不过如果你传了自定义比较函数并且比较函数里能自行处理Symbol那它也可以参与排序但实际业务中极少有这种需求。知道这个行为免得哪天真碰上了摸不着头脑。2. 比较函数到底在干嘛三种返回值决定一切2.1 返回值是“方向”而不是“差值”当你给sort传入一个比较函数时引擎会把数组里的元素两两丢进这个函数然后根据返回值来决定谁在前谁在后。这个返回值其实只需要看三种状态返回负数a排在b前面返回0a和b的相对顺序保持不变返回正数b排在a前面写成代码长这样arr.sort((a, b) { // 负数 - a 在前 // 0 - 保持原顺序 // 正数 - b 在前 });可以把它理解成一个“方向指示器”比较函数在回答“a 和 b 谁更靠前”这个问题。至于返回的具体绝对值是多少引擎并不关心它只关心符号。所以(a, b) a - b和(a, b) a b ? 1 : -1在大多数时候效果是相同的只是后者在相等时返回了-1而不是0这又会破坏稳定性后面我会专门说。这里有一个比较容易踩的暗坑如果比较函数返回了NaN比如abc - 123这种运算结果引擎会把NaN当成0来处理相当于告诉它“这两个元素相等”。结果就是排序过程里本该参与大小比较的元素被跳过了数组顺序会很混乱而且不同引擎下的表现还不一定完全一致。2.2 升序、降序、对象属性排序的正确姿势数值数组升序最经典的写法就是const nums [10, 9, 100, 2]; nums.sort((a, b) a - b); // [2, 9, 10, 100]降序则是把减法反过来nums.sort((a, b) b - a); // [100, 10, 9, 2]为什么a - b就是升序因为当a小于b时a - b是负数负数表示“a 排在 b 前面”于是小的在前。当a大于b时a - b是正数表示“b 排在 a 前面”于是大的被移到后面。降序就把减法顺序调转过来逻辑完全对称。如果是对象数组按某个属性排序也简单const users [ { name: C, age: 30 }, { name: A, age: 20 }, { name: B, age: 25 }, ]; users.sort((a, b) a.age - b.age); // 结果按 age 升序排列如果是字符串数组直接a - b会得到NaN所以要用localeCompareconst fruits [banana, apple, cherry]; fruits.sort((a, b) a.localeCompare(b));localeCompare返回的正好就是负数、0、正数三种状态语义上和sort完美匹配。顺带一提比较日期对象时也推荐用a.date - b.date或者.getTime()做差值日期对象可以隐式转成毫秒时间戳减法非常方便。2.3 新手常犯返回布尔值导致排序错乱我见过大量类似写法arr.sort((a, b) a b);看起来好像是“大于就把 b 排前面”但这是错的。a b的结果只有true或false转成数字后是1或0。这里没有任何负数出现的可能。当a小于b时a b为false返回0引擎会认为 a 和 b 相等不调整顺序。于是该交换的没交换数组最终呈现出一种“半排序但又不完全对”的状态。更严重的是这种不完整的比较器在不同引擎、不同数据分布下产生的结果可能还不一样非常难排查。排查时最直接的手段是把比较函数单独抽出来检查它返回的值到底有没有负数。我见过有人愣是调了两小时排序逻辑最后发现只是把a - b写成了a b。3. 引擎底层用什么排序算法从快排到稳定混合排序3.1 V8 的算法演进逻辑为什么不用单纯的快排了聊sort原理绕不开引擎实现。以 V8Chrome 和 Node.js 使用的 JavaScript 引擎为例它的排序算法曾经经历过一次重要变化。早期 V8 的策略是数组长度小于某个阈值时用插入排序大于阈值时用快速排序。这个方案并不是拍脑袋决定的。插入排序在最坏情况下是 O(n²)但在小数组上常数极小、实现简单CPU 缓存也友好所以“短数组用插入排序”几乎成了排序算法的标准玩法。快速排序是原地排序额外空间占用少平均复杂度 O(n log n)所以大数组交给它。但这个组合有两个硬伤快速排序不稳定而且最坏情况下会退化到 O(n²)。不稳定的意思前面提过就是比较结果相同的元素排序后相对顺序可能被调换。这在很多业务场景里是不能接受的。比如先把数据按时间排序再按部门分组排序如果底层排序不稳定同组内的时间顺序就全乱了。所以 V8 7.0 之后Chrome 70 开始V8 把排序算法切成了以 Timsort 为蓝本的稳定混合排序短数组用二分插入排序长数组则以归并思路为主充分利用数组中已经有序的片段。Timsort 最坏复杂度是 O(n log n)最好情况下能到 O(n)而且稳定。代价是需要额外的 O(n) 空间但对现代 JavaScript 引擎来说这点内存开销换稳定性和整体性能非常划算。3.2 其他主流引擎的排序实现算法可以不同但都必须稳定除了 V8其他主流 JavaScript 引擎也各有各的排序实现。Firefox 的引擎长期使用归并排序所以它在很多年前就一直是稳定的。Safari 使用的 JavaScriptCore 也实现了稳定的排序算法。也就是说在 ES2019 之前不同浏览器对同一个数组执行sort结果可能真的不一样尤其体现在相等元素的相对顺序上。你在一台机器上肉眼验证通过换台机器可能顺序就变了。ES2019 规范把“稳定排序”正式写进了标准之后所有符合规范的引擎在Array.prototype.sort上都必须稳定。规范只约束“结果必须稳定”并没有规定具体用哪个算法所以不同引擎内部可以继续各玩各的有的偏重常数优化有的偏重最坏情况表现。这道防线对业务开发者是好事但我们也不能因此就写出依赖“比较函数调用次数”之类的代码实现细节依然可以变化。3.3 稳定排序到底意味着什么举个具体例子稳定排序用一句话说就是“比较器返回 0 时原来在前面的元素依然在前面”。比如这样一个数组const students [ { name: Bob, grade: 90 }, { name: Alice, grade: 90 }, { name: Cara, grade: 80 }, ]; students.sort((a, b) b.grade - a.grade);如果底层是稳定排序Bob 和 Alice 的grade相等比较函数返回 0那么排序后 Bob 依然在 Alice 前面。如果底层是不稳定的快排这两个元素的顺序可能被交换。在很多“按分数倒序同分按姓名顺序”的多级排序场景里稳定性决定了前面轮次的排序结果会不会被后面轮次破坏。如果你发现同分数据的内部顺序每次跑都不一样可以先怀疑引擎稳定性但更早的版本里这就是老快排的本性。4. 5 个高频排序场景的完整写法4.1 数字和数字字符串排序减法不是万能钥匙最常见需求就是数值排序上面已经给出写法。这里提一个延伸如果数组里存的是字符串形式的数字比如[10, 9, 100]用(a, b) a - b也能正常排。因为减法运算会先把字符串隐式转成数字再计算效果等价于数值排序。但如果数组里混了一个转不成数字的字符串比如abca - b就会得到NaN如前面所说NaN被当成 0排序效果就会出问题。所以在业务里我建议先把数据清洗成统一类型再交给sort别让比较器帮你隐式转换兜底。4.2 中文按拼音排序localeCompare 的正确打开方式中文排序在业务里很常见。最朴素的写法是const names [张三, 李四, 王五, 王二]; names.sort((a, b) a.localeCompare(b, zh));大多数现代浏览器在指定zh语言标签后会按拼音顺序排序。但这里有个隐藏条件最终取决于运行环境内置的 ICU 数据是否完整。一些体积精简过的运行环境可能没有完整中文排序数据导致结果变成按 Unicode 编码排看起来像随机顺序。如果项目对中文排序有硬性要求最好是维护一份拼音映射表或者使用专门的排序库不要把命运完全交给localeCompare。localeCompare还能处理带数字的字符串比如第2章和第10章。默认字符串比较会把第2章排在第10章前面吗不会2比1的编码大所以会得到反直觉的结果。这时可以加选项[第2章, 第10章, 第1章].sort((a, b) a.localeCompare(b, zh, { numeric: true }) ); // 结果: [第1章, 第2章, 第10章]{ numeric: true }让比较器识别数字片段并按数值大小排序这个选项在排版本号、文件名时特别有用。4.3 多条件排序先按 A 再按 B 的标准写法业务里最常见的复杂排序需求是“先按一个字段相等再按另一个字段”。最直白、最不会出错的写法是users.sort((a, b) { if (a.age ! b.age) { return a.age - b.age; } return a.name.localeCompare(b.name); });原理很简单第一个条件不为 0直接返回为 0 说明当前字段相等继续用第二个条件比较。利用“0 是假值”这个特性还能简写成users.sort((a, b) (a.age - b.age) || a.name.localeCompare(b.name));||左侧是 0 时才会去计算右侧逻辑和 if 写法完全等价。不过要注意这种简写对可读性有一定牺牲如果排序条件超过三个我还是推荐写注释分明的 if 版本。想更工程化可以抽一个比较器组合函数出来function by(...comparators) { return (a, b) { for (const compare of comparators) { const result compare(a, b); if (result ! 0) { return result; } } return 0; }; } users.sort(by( (a, b) a.age - b.age, (a, b) a.name.localeCompare(b.name), ));这套组合模式在多字段排序的需求里复用性很好也方便单测。4.4 不可变排序与稀疏数组两个容易被忽视的细节sort是原地排序会直接修改原数组。如果业务上要求原数组不变记得先拷贝再排const sorted [...arr].sort((a, b) a - b);但要注意这是浅拷贝。如果数组元素是对象sorted里的对象引用和原数组仍然是同一个排序不会改对象本身可后续如果你修改了某个对象的属性原数组对应元素也会跟着变。稀疏数组是另一个容易被忽略的点。sort对空洞有特殊规则空位会被当作undefined处理并排到末尾排序结束后空洞可能被填充成显式的undefined元素。也就是说稀疏数组经过排序后长度不变但空洞的位置会变化甚至消失。业务代码里不要依赖“排序后空洞还在原来位置”这种假设真要处理稀疏数组先Array.from或者filter填充成普通密集数组反而更可控。4.5 老环境下强制稳定排序的兼容技巧虽然 ES2019 之后标准要求稳定排序但碰上老版本环境或者某些混合运行时稳定性依然可能不达标。这时候可以用“装饰-排序-还原”模式给每个元素绑上原始索引让比较器在 key 相等时用索引兜底function stableSort(arr, compareFn) { return arr .map((item, index) ({ item, index })) .sort((a, b) compareFn(a.item, b.item) || a.index - b.index) .map(({ item }) item); }核心思路是原始索引永远不同的单调递增属性比较器在业务 key 相等时返回索引差值强制保持原顺序。这个技巧在老浏览器兼容场景里非常有价值。5. 排序性能调优比较成本高的时候怎么办5.1 比较器调用次数与成本别把重活写在回调里很多人在排序时容易忽略一个事实比较函数会被引擎反复调用次数大约是 O(n log n)。500 个元素大概几千次调用10 万元素就可能达到百万次级别。如果比较函数内部做了耗时操作比如localeCompare、正则匹配、new Date().parse()排序耗时会成倍上升。更难受的是有些比较函数会把同一个计算重复做很多次。比如排商品列表时每次比较都用正则从¥1,200里解析数字一万条数据就可能解析几十万次。这种问题不是sort本身的性能问题而是比较器的低效放大到了排序的每一轮比较里。优化方向是把“计算排序 key”从比较逻辑里拿出来每项只算一次然后在比较时直接使用预计算好的 key。5.2 装饰-排序-还原模式把重计算提前刚才提到的模式正好可以用来做性能优化业界也常叫施瓦茨变换。思路是先 map 把原始数据和排序 key 绑在一起排序完成后再还原回原数组。const data [ { name: A, rawPrice: ¥1,200 }, { name: B, rawPrice: ¥9,900 }, { name: C, rawPrice: ¥800 }, ]; const decorated data.map((item, index) ({ index, price: Number(item.rawPrice.replace(/[^\d]/g, )), })); decorated.sort((a, b) a.price - b.price || a.index - b.index); const sortedData decorated.map(({ index }) data[index]);这个例子里正则和Number转换在 map 阶段只执行一次排序阶段比较函数只做纯数字减法性能差距非常明显。数据量越大收益越大。如果排序对象本身不可变或者不想让中间对象带原始数据这个模式依然适用。5.3 TypedArray 和普通数组的排序差异默认行为不一样很多从普通数组转过来的开发者第一次写 TypedArray 排序也会踩坑。TypedArray.prototype.sort的默认行为跟普通数组不同它默认就是按数值升序而不是转字符串。所以new Uint8Array([10, 9, 100]).sort(); // 结果是 Uint8Array [9, 10, 100]而普通数组[10, 9, 100].sort()的结果是[10, 100, 9]。这个差异来自两种数组的设计目标不同TypedArray 的元素本身就是数值类型不需要猜测普通数组的元素可以是任意值规范选了字符串排序作为默认。日常写代码时如果遇到类数组或者二进制数据处理别把普通数组那套默认行为直接套上去。5.4 什么时候不该用内置 sortTopK 场景如果只需要拿到最大的 5 个元素或者最小的 3 个元素对整个数组做一次完整排序是浪费的。全排序复杂度至少 O(n log n)TopK 问题用堆或者快速选择可以做到 O(n log k)数据量大的时候差距显著。内置sort没有专门暴露 TopK 的入口碰到这种需求自建一个小顶堆或者大顶堆反而是更合理的方案。// 找出最大的前 k 个维护一个大小为 k 的小顶堆 function topK(arr, k) { const heap arr.slice(0, k).sort((a, b) a - b); for (let i k; i arr.length; i) { if (arr[i] heap[0]) { heap[0] arr[i]; heap.sort((a, b) a - b); } } return heap; }这个版本不是最优实现但足够说明思路如果只关心头部数据别让全量排序拖慢你。6. 常见排序翻车现场问题与排查实录6.1 比较函数返回布尔值第一类经典翻车归总一下前面反复提到的错误arr.sort((a, b) a b); // 期望升序实际乱序 arr.sort((a, b) a b); // 期望降序实际乱序排查技巧很简单写一个高阶函数把每次比较的返回值打出来看看符号分布function debugSort(arr, compareFn) { const copy [...arr]; let calls 0; const result copy.sort((a, b) { calls; const value compareFn(a, b); console.log(第 ${calls} 次比较:, a, b, , value); return value; }); console.log(总比较次数: ${calls}); return result; }只要看到返回值全是false、true或者全是0和1就说明比较器本身没写对。先修比较器再谈底层算法能省一大半排查时间。6.2 sort 做随机洗牌又一个经典误区很多人喜欢用return Math.random() - 0.5来实现数组随机打乱arr.sort(() Math.random() - 0.5);表面上能“随机”实际上有两个严重问题。第一比较函数应该是确定性的同一个元素对不应该在一次排序过程中被反复随机判断。随机返回值会让排序算法内部的比较结果自相矛盾可能触发非预期的算法路径甚至出现分布不均。第二即使它能跑概率也不是均匀的某些顺序出现的频率会明显偏高。正确的洗牌方式是 Fisher-Yates 洗牌function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; }这个算法每次从剩余元素里等概率取一个交换到尾部概率分布均匀复杂度 O(n)没有任何争议。6.3 NaN、Infinity、null、undefined 的排序表现速查实际业务数据总是脏的下面这张表建议收藏值默认排序数值比较器 (a - b)注意undefined排到数组末尾排到数组末尾不参与比较器调用null转为字符串 null转为 0在符号排序里可能意外混进负数区NaN转为字符串 NaN比较结果 NaN被当作 0排序会失效元素位置几乎不变Infinity字符串 Infinity正常按大数排序数值排序里它比任何有限数都大Symbol默认排序直接抛 TypeError取决于比较器实现除非自定义比较器否则别让 Symbol 进数组这张表反映了一个核心原则排序前先检查数据脏数据清洗比排序本身更重要。6.4 比较函数必须满足一致性别搞出自相矛盾比较函数除了返回值要对还必须满足传递性如果a排在b前面b排在c前面那么a一定排在c前面。随机洗牌那种写法是最典型的非一致比较器。还有一些看起来像逻辑的写法比如(a, b) a % b - b % a也会制造循环矛盾。不一致的比较器会让排序算法进入未定义的行为区域结果不稳定、不同数据量结果不同、甚至可能触发引擎内部异常路径。写比较器的时候思维要停留在“比较两个独立元素”的层面不要引入任何依赖全局状态或随机数的逻辑。6.5 排序后引用类型被修改原地排序的副作用最后提醒一个比较容易忽略的点sort是原地修改很多开发者会忘记sort之后原数组已经变了。如果你基于“原数组还保持最初顺序”的假设去继续处理数据就会产生隐蔽的 bug。需要保留原始顺序就先拷贝对象数组则要意识到拷贝的是引用。我个人在实际操作中的习惯是一旦要写排序先明确“原数组可不可以被改动”。可以那就直接sort不可以就先slice或[...arr]。这个判断往往在写代码之前就定好了能避免后续一堆副作用争议。最后分享一个小技巧调试排序永远先看比较器多写几年代码之后你会发现排序结果不对十有八九不是算法问题而是比较器写歪了。我排查排序问题的固定流程是先把比较函数单独抽出来扔进一个纯函数测试集里用 3-5 个手工构造的用例验证返回值是否符合“负数、0、正数”的语义再把比较器放进debugSort里看整体调用过程。等比较器本身完全可信再去怀疑引擎和稳定性那时候才轮到算法原理登场。希望这篇内容能让你下次写sort时少踩几个坑尤其是默认排序、布尔值、稳定性和性能那几关。

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

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

免费获取报价 →
↑