☰
JavaScript数组对象排序完全指南:从sort原理到实战避坑
2026/10/23 7:13:42 网站建设 项目流程

排序这个功能,恐怕是前端日常开发里最常见的需求之一了。不过数组对象排序和简单数组排序不一样,它背后藏着一些很容易被忽略的细节。很多人会直接调用sort()然后发现结果不对,或者写了个比较函数却排出来乱序,甚至因为排序修改了原数据导致页面出现莫名奇妙的 bug。这篇文章我会把 JavaScript 数组对象从小到大排序这件事完整拆开讲:从sort()的底层机制、比较函数的写法,到字符串、日期、空值、嵌套字段等复杂场景的处理,再到稳定性和性能的实战经验。适合刚入门的前端新手,也适合写了几年业务代码却一直对排序细节似懂非懂的开发者。

1. 排序前先搞清楚 sort 的底层逻辑

1.1 默认排序为什么不是你以为的数字顺序

先用一个最简单的例子说明问题:

const arr = [10, 9, 100, 2]; arr.sort(); // 结果不是 [2, 9, 10, 100],而是 [10, 100, 2, 9]

很多人第一次看到这个结果都会发懵:我明明排的是数字,为什么 10 会排在 2 前面?因为sort()在没有传入比较函数时,会把数组里的每一项都转成字符串,然后按照字符串的 UTF-16 码元序列进行比较。字符串比较是一个字符一个字符比过去的,'10' 和 '2' 比较时,先比较 '1' 和 '2','1' 的码元是 49,'2' 的码元是 50,所以 '10' 会被认为小于 '2'。

可以把它想象成查字典:字典里 "apple" 排在 "banana" 前面,是因为 'a' 小于 'b',而不是因为哪个词更长。数字字符串的比较逻辑也是这样的,它根本不关心这个字符串代表的数值大小。所以,只要你的数组里是数值类型,就必须传入比较函数,否则排序结果大概率不是你想要的样子。

1.2 比较函数的三条铁律

数组对象排序的核心在于给sort()传入一个比较函数(compareFunction),这个函数接收两个参数 a 和 b,返回一个数字。这个返回值的正负决定了排序顺序:

  • 返回值小于 0:a 排在 b 前面
  • 返回值大于 0:a 排在 b 后面
  • 返回值等于 0:a 和 b 的相对位置保持不变(前提是排序算法稳定)

拿最常见的数字升序来说,比较函数可以写成:

arr.sort((a, b) => a - b);

这个写法背后的逻辑是:如果 a 小于 b,那么a - b是负数,a 就排在 b 前面;如果 a 大于 b,那么a - b是正数,a 排在 b 后面。整个过程可以理解为「拿 a 和 b 做一次减法,用减法的结果告诉排序引擎谁大谁小」。只要理解了这三条铁律,数组对象排序就没有任何秘密了——无非就是把 a 和 b 从对象里取出你要比较的那个字段,再做减法或者比较。

这里顺便说一个新手常见误区:有些同学会把比较函数写成(a, b) => a > b ? 1 : -1,这其实也能用,但会丢失「相等返回 0」的信息。虽然现在的主流排序算法是稳定的,从结果上看大概率没问题,但严格来说,相等的元素应该返回 0,否则在某些边界场景下会出现额外的不必要交换。所以最简单可靠的写法就是直接做减法,或者用<、>搭配 ternary 时把相等的情况单独返回 0。

2. 数组对象排序的三种常见写法

2.1 直接按数字字段排序

假设有一个用户列表,需要按照年龄从小到大排序:

const users = [ { name: '张三', age: 32 }, { name: '李四', age: 28 }, { name: '王五', age: 35 }, { name: '赵六', age: 28 }, ]; users.sort((a, b) => a.age - b.age);

这段代码在实战中出现频率极高。它做的事情只有一件:取出 a 的 age 字段和 b 的 age 字段相减,根据正负决定顺序。运行结果会得到按 age 升序排列的新数组,同时也会修改原来的users数组。这个原地修改的特性我们后面会专门讲,这里先提个醒:如果你不希望原数组变乱,一定先复制一份再排序。

对于数字字段,直接用减法是最快的做法,没有函数调用开销,也不需要额外转换。如果字段本身是字符串形式的数字,比如age: '28',那你得先把字段转成数字,否则相减时会发生隐式类型转换,结果倒是能排,但遇到空字符串、null 这种值的时候容易产生 NaN。更稳妥的做法是在数据源层面统一处理。

2.2 按字符串字段排序与 localeCompare 的坑

按字符串字段排序就不能用减法了。两个名字、标题、分类名之间的比较,需要用到字符串比较。最直接的方式:

users.sort((a, b) => a.name.localeCompare(b.name));

为什么不用a.name - b.name?因为字符串减法是 NaN,没有任何意义。为什么不用a.name < b.name ? -1 : 1?这个写法能处理英文大小写不敏感的场景吗?不能。它比较的是码元序列,'apple'与'Banana'比较时,因为大写字母的码元(65~90)小于小写字母(97~122),所以'Banana'反而会排在'apple'前面,这跟英文词典的排序习惯不一样。

localeCompare是字符串原生的比较方法,它基于语言环境的规则来比较两个字符串,可以处理大小写、变音符号等。用法是a.name.localeCompare(b.name),返回值同样是负数、0、正数,正好满足比较函数的要求。它可以接收两个额外的参数:locales和options。比如:

// 指定中文环境,按拼音排序 a.name.localeCompare(b.name, 'zh'); // 开启数值化选项,"a10" 会排在 "a9" 后面 a.name.localeCompare(b.name, 'zh', { numeric: true });

这里有个很容易踩的坑:中文排序。如果你直接localeCompare(b.name)不指定 locale,不同浏览器的默认排序行为可能不一致,有的按 Unicode 顺序排,有的按拼音排,有的按部首排,结果会很迷惑。建议明确指定'zh'或者根据你的业务场景选择'zh-Hans-CN'等具体地域标签。我第一次处理中文客户名称排序时就被坑过,同一个数组在 Chrome 和 Firefox 里排序结果不一样,最后查到原因就是 locale 默认值不一致,改成明确指定后结果才稳定。

2.3 多条件排序怎么叠加

实际业务里经常遇到「先按部门排,再按姓名排」或者「优先按状态,再按时间」这类需求。多条件排序的写法并不复杂,核心思想是:主要条件优先,只有当主要条件相等(返回 0)时,才继续用次要条件比较。

users.sort((a, b) => { // 先按年龄升序 const ageDiff = a.age - b.age; if (ageDiff !== 0) return ageDiff; // 年龄相同时,按姓名升序 return a.name.localeCompare(b.name); });

如果你想写成一行,可以用逻辑或||:返回第一个非零值,没有非零值时就是 0。

users.sort((a, b) => (a.age - b.age) || a.name.localeCompare(b.name));

这个写法利用了逻辑或的短路特性:如果年龄差不是 0,直接返回年龄差,不执行后面的比较;如果年龄差是 0,就去比较姓名。注意这里的顺序很重要,主要条件必须写在最前面,否则次要条件会覆盖主要条件。

举一个反面例子:假设我想按「优先级高的在前面、截止日期晚的排前面」来排任务。有些人会写:

tasks.sort((a, b) => a.priority - b.priority || b.dueDate - a.dueDate);

这个写法是对的。但如果你把两个条件写反,变成先比较dueDate再比较priority,那么相同截止日期的任务里,优先级更高的不会排到更前面;甚至因为排序是稳定的话,也有可能覆盖你原本想要的优先级顺序。所以写多条件排序时,一定要在注释里写清楚「先按什么排,再按什么排」,否则隔一个月回来看代码,很容易搞混方向。

3. 进阶场景:日期、空值、布尔值与嵌套字段

3.1 日期字符串排序

后端接口返回的日期字段通常是字符串,常见的有两种:ISO 8601 格式,比如'2024-05-20T08:30:00.000Z',以及'2024/05/20'、'2024-05-20'这类日期字符串。

ISO 字符串可以直接比较,因为它的设计保证字典序和实际时间序一致。比如:

events.sort((a, b) => a.startTime.localeCompare(b.startTime));

或者直接用<比较:

events.sort((a, b) => (a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0));

但'2024/05/20'这种斜杠格式就不能直接比了,因为'2024/10/01'和'2024/9/01'比较时,字符串 '2024/9/01' 会小于 '2024/10/01'(字符 '9' 大于 '1'),如果月份是 9 月和 10 月,按字典序就会出错。所以遇到非标准的日期字符串,稳妥的做法是先转成统一的时间戳或标准格式再比较:

events.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));

这里注意new Date的解析在不同浏览器里对'2024/05/20'这种格式支持还行,但如果你的日期是'20-05-2024'这种,不同 JS 引擎解析结果可能完全不同。所以最保险的办法是让后端统一返回 ISO 格式,或者前端先把日期字符串解析成时间戳存成数字字段。

3.2 空值(null 和 undefined)的处理

接口数据里经常出现某个字段为null或undefined的情况。直接拿来做减法会得到NaN,而NaN在比较函数里既不返回负数也不返回正数,排序引擎会把它当成 0 处理,结果就是这些元素的位置完全无法预测,非常容易出骨牌效应。

处理空值的思路是:先判断字段是否为null/undefined,把空值统一放到数组的末尾(或开头,按业务决定),再对非空值做正常比较。

const list = [ { id: 1, score: 88 }, { id: 2, score: null }, { id: 3, score: 92 }, { id: 4, score: undefined }, ]; list.sort((a, b) => { const aVal = a.score; const bVal = b.score; // 空值放在最后 const aEmpty = aVal == null; const bEmpty = bVal == null; if (aEmpty && bEmpty) return 0; if (aEmpty) return 1; // a 是空值,排后面 if (bEmpty) return -1; // b 是空值,排后面 return aVal - bVal; });

注意这里用了== null,它同时判断了null和undefined,是一种简洁的判断写法。如果你只想把null排最后而不处理undefined,就把undefined单独拎出来处理。空值的处理优先级一定要放在最前面,因为它会直接影响后续比较的合法性。我做过一个报表排序功能,因为没处理空值,有几个排行行数据时不时跳位,排查花了很长时间,最后才发现是score字段偶发为空导致排序不稳定。

3.3 嵌套对象字段排序

数组里的元素是对象,对象的属性可能又是一个对象,比如user.address.city。这种情况下的排序思路一样,只是取值路径变长了:

users.sort((a, b) => { const cityA = a.address?.city ?? ''; const cityB = b.address?.city ?? ''; return cityA.localeCompare(cityB); });

这里用了可选链?.和空值合并??,可以避免a.address为undefined时直接报错。为嵌套字段设置默认值是一个很实用的习惯,尤其是从接口取数据时,你往往不能保证每层都有值。

另外,如果嵌套层级比较深,排序比较函数里每次都去解析a.b.c.d,会带来一些性能开销。如果数据量很大,可以考虑先做一次数据规整,把需要排序的字段提取成浅层属性,比如:

const prepared = users.map(user => ({ ...user, _sortCity: user.address?.city ?? '', })); prepared.sort((a, b) => a._sortCity.localeCompare(b._sortCity));

排序完成后再删掉_sortCity或者直接使用预处理后的数据。这种方法在数据量上万时提升非常明显,后面性能部分还会再提到。

4. 排序稳定性与性能实测

4.1 稳定性是什么,为什么重要

排序算法的稳定性指的是:如果两个元素在排序前的相对位置是 a 在前 b 在后,并且比较函数认为它们相等(返回 0),排序后 a 仍然在 b 前面。

比如有一个学生列表,先按班级分组,班级乱序;现在只想按成绩排序,希望同分的学生保持原来的班级相对顺序:

const students = [ { name: 'A同学', class: 1, score: 90 }, { name: 'B同学', class: 2, score: 85 }, { name: 'C同学', class: 1, score: 90 }, { name: 'D同学', class: 2, score: 85 }, ]; students.sort((a, b) => a.score - b.score);

如果排序不稳定,那么两个 90 分的 A 和 C、两个 85 分的 B 和 D,顺序可能会颠倒。但根据 ES2019 规范,Array.prototype.sort必须是稳定的,主流的现代 JavaScript 引擎也都遵守了这个规定。所以你现在写的排序代码,在 Chrome、Firefox、Node.js 这些环境里都是稳定的。唯一需要担心的是旧版 IE 或非常古老的 WebView,不过现在这种环境已经很少见了。

稳定性在实际业务里很有用。比如你希望任务列表先按「是否加急」分组,再按「创建时间」排序。可以先按加急状态排,再按创建时间排,由于排序是稳定的,第二次排序不会破坏第一次加急分组的效果。如果不稳定,你就要写一个多条件比较函数,把两个条件同时放在判断里。

4.2 大量数据时注意函数调用开销

sort()的比较函数会在排序过程中被反复调用。以快排类算法为例,排序 n 个元素,比较函数的调用次数大约在 O(n log n) 数量级。也就是说,10000 条数据大约调用 13 万次比较函数;100000 条数据大约调用 170 万次。如果比较函数里每个字段都去深层取值、做大量计算,性能会肉眼可见地变差。

我在一个管理后台里排过一份 5 万行的报表,原来比较函数里写了类似Number(formatter(val.date))这样的转换逻辑,排序耗时将近 2 秒,用户明显感觉卡顿。后来改成预处理:

const rows = rawData.map(row => ({ ...row, _sortTime: new Date(row.dateStr).getTime(), _sortId: Number(row.idStr), })); rows.sort((a, b) => a._sortTime - b._sortTime);

排序耗时直接降到 200ms 以内。这个优化方案本质上是把「每次比较都要做的重复计算」提前做了一次,用空间换时间。

另外一个容易被忽略的点:比较函数里不要直接修改外部变量。虽然 JavaScript 引擎不会因此报错,但在排序过程中修改外部状态会让结果不确定,也更难调试。比较函数应该是一个纯函数:同样的输入永远得到同样的输出,不依赖外部状态。

4.3 不可变性:复制后再排序

sort()会原地修改数组。这意味着你调用list.sort(...)之后,原本的list数组顺序已经变了。如果你的代码里其他地方还依赖于原数组的顺序,比如列表渲染、筛选、统计,很可能出现「页面数据莫名其妙变了」的 bug。

想要保持原数组不变,先做一次浅拷贝再排序:

const sortedList = [...list].sort((a, b) => a.age - b.age);

注意[...list]只是浅拷贝,数组里的对象元素仍然是同一个引用。如果你在排序过程中修改了对象的属性,原对象也会变。如果排序仅仅是比较字段,不改属性,那么浅拷贝就够用了。如果你需要深拷贝对象数组,最简单的方式是JSON.parse(JSON.stringify(list)),但这种方式序列化有一定开销,也会丢失函数、undefined 等特殊属性,业务场景中按需使用。

我在实际项目中养成了一个习惯:给所有排序操作统一走一个工具函数,函数内部先复制再排序,返回新数组。这样调用方永远不用担心数据被意外修改。比如:

function sortArrayObject(array, compareFn) { const copy = array.slice(); copy.sort(compareFn); return copy; }

这个工具函数虽然很简单,但能避免非常多隐藏 bug,强烈推荐在团队里统一使用。

5. 常见问题与排查技巧实录

5.1 典型错误对照表

现象原因解决方案
数字排序结果为 [10, 100, 2, 9]没传比较函数,按字符串排传(a, b) => a - b
字符串排序大小写不敏感结果不稳定直接用>或<比较使用localeCompare
中文姓名排序跟预期不符未指定 locale用localeCompare(b.name, 'zh')
有空值字段时排序结果跳动比较函数得到 NaN处理null/undefined
原数组顺序被修改sort()原地排序先浅拷贝再排序
多条件排序优先级不对次要条件写在主要条件前面调整判断顺序
日期字符串排序错乱非 ISO 格式按字典序比较new Date()转时间戳
深层嵌套字段排序慢比较函数里多次取深层属性预处理提取排序字段

5.2 调试技巧:打印比较函数的执行过程

很多时候排序结果不对,但你盯着比较函数看半天也看不出问题。我的经验是直接把比较函数里拿到的 a 和 b 打出来,看看它们的字段到底是什么类型。

const list = [{ n: '1' }, { n: 1 }, { n: '10' }, { n: 2 }]; list.sort((a, b) => { console.log('a.n:', a.n, '类型:', typeof a.n); console.log('b.n:', b.n, '类型:', typeof b.n); return a.n - b.n; });

看到输出之后,你就会发现有三个元素是字符串、一个是数字,减法虽然发生了隐式转换,但字符串 '10' 又会按 Number 转换后参与比较,所以结果碰巧能排对。但如果你改成a.n < b.n ? -1 : 1,或者某个元素是空字符串,结果就会完全不一样。所以调试排序问题,第一件事就是确认排序字段的类型是否统一。

5.3 高性价比优化:利用 Intl.Collator

对于字符串排序,localeCompare虽好用,但如果有大量字符串需要比较,它还是会带来不小开销。更好的方式是用Intl.Collator预先创建一个比较器:

const collator = new Intl.Collator('zh', { numeric: true }); users.sort((a, b) => collator.compare(a.name, b.name));

这里把 locale 规则和选项放在比较器里,比较器内部做了预编译,速度和稳定性都比直接调用localeCompare好一些。实测在 2 万条中文用户数据场景下,使用Collator比localeCompare快百分之二三十左右。如果你的排序字段是英文字母或纯中文且数据量不大,区别不明显,但养成这个习惯总没坏处。

5.4 排序字段统一类型是治本之策

最后我想强调一个容易被忽略的原则:排序字段的类型一定要统一。如果你发现自己在比较函数里频繁写Number(a.age)、String(a.name)这类转换,那说明数据源本身就有问题。最好的处理方式不是在比较函数里兜底,而是在数据进入排序逻辑之前,先做一次清洗。

比如后端返回的年龄字段,有时候是 28,有时候是 "28",有时候是 null。你可以在拿到数据后统一处理成数字,空值统一当作 0 或者过滤掉。这一步最好放在数据请求层或者 store 层,这样排序、筛选、计算都能拿到干净的数据。我参与过的项目里,很多排序 bug 的根因都不是排序代码本身,而是数据里混进了不同的类型。把这条原则贯彻到团队里,能省下大量调试时间。

写在最后

数组对象排序看似简单,但我在实际项目中没少因为它踩坑。最深的体会是:写排序之前先想清楚三件事——字段类型是否统一、是否需要保留原数组顺序、要不要处理空值。这三件事想清楚了,排序代码通常不会出大问题。另外,如果团队里经常有人在这块犯错,不妨把排序封装成通用工具,明确约定返回新数组、空值策略和多条件顺序。这样大家写业务代码时,只需要关心「按哪个字段、升序还是降序」,其他的坑都提前绕开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询