资讯详情

资讯详情

原生JS对象数组按key排序:通用比较函数与空值多字段处理

上周帮一个同事看他那个后台表格点表头排序数据一刷新顺序就乱了第二次点同一列又变了个花样。需求本身一点都不新鲜原生js实现对象数组根据key值排序一行sort加一个比较函数的事网上能搜出几百份答案。但真把它塞进业务数据里问题往往不是怎么写而是为什么这么写还是不对。比如数字被当成字符串比了、null全跑到最前面、点两次箭头顺序不一致、排完发现原始数组被改掉了导致某个组件没刷新。这篇就把这件事从头到尾拆一遍对象数组按 key 值排序到底有哪些隐性分支比较函数的返回值在引擎里是怎么被消费的怎么封装一个能覆盖数字、字符串、中文、日期、嵌套字段、空值的通用排序器以及我在实际项目里踩过的那些坑。内容偏实战前端新手能直接抄代码有经验的同学可以重点看第 2 章和第 5 章那两块才是排序翻车的高发区。1. 先把需求拆开表层是排序底层是把值变成可比值很多人写排序的思维顺序是反的上来就写(a, b) a[key] - b[key]跑通了就提交。正确的顺序应该先问三个问题——这个 key 取出来的值是什么类型可能有哪些脏数据比不出来的时候我要它排在哪这三个问题的答案决定了比较函数长什么样而不是反过来。1.1 三个高频场景各有各的坑第一个场景是表格列头排序。用户点金额要按数值排点创建时间要按时间排点名称要按拼音排。这里 key 是动态的你不能为每一列写一个比较函数必须做成sortBy(list, amount, desc)这种配置式调用。动态 key 带来的第一个问题是取值方式不统一item.amount和item.user.name得走同一套逻辑第二个问题是降序要靠布尔开关而不是改比较函数体否则十列就要写十份比较函数。第二个场景是列表分组后的组内排序。比如一个订单列表先按状态分组每组内部再按时间倒序。这时候排序对象已经不是扁平数组了而是{ status, items: [...] }这样的结构你需要对每个组分别调用排序器并且保证分组本身的顺序不受影响。这里最容易犯的错是直接对group.items调sort结果把源数据改了页面其他地方引用的还是同一份引用出现排序突然串到另一个模块的灵异现象。第三个场景是多字段组合排序。典型例子是成绩单先按总分降序总分相同按语文降序语文还相同按学号升序。这种需求用单个比较函数硬写会变成一团嵌套三元表达式可读性极差而且每加一个维度都要重写。正确做法是把排序规则做成数组逐条比较第一条分出胜负就直接返回。提示如果你在写排序之前没有明确数据里可能存在哪些类型那就先去控制台跑一遍Object.keys加typeof把真实的字段类型摸清楚。这一步花两分钟能省掉后面半小时的调试。1.2 原生 sort 给了你什么又拿走了什么Array.prototype.sort的本质是一次比较排序它只负责两件事调用你给的比较函数、根据返回值搬动元素。它不负责类型转换不负责空值处理不负责告诉你这两个元素其实比不出来。默认行为更危险——不传比较函数时所有元素会被转成字符串按 UTF-16 码元顺序比于是[10, 9, 100]排出来是[10, 100, 9]。这个行为在 MDN 上有明确说明但每年还是能看到有人被它坑到。它还会原地修改原数组并返回同一个引用。这个设计在纯数据处理的脚本里很方便但在任何有状态管理的地方都是隐患组件里拿到的是同一份引用diff 逻辑认为数据没变视图就不更新。所以我的习惯是排序器永远返回新数组内部用slice()或者装饰法复制一份再排。还有一个被忽略的点是稳定性。ES2019 之后规范要求sort必须稳定也就是相等元素保持原有相对顺序。现代引擎都遵守这条但如果你的运行环境比较老或者你在自己实现一个排序就要手动加索引兜底比较结果为 0 时用原始下标做二次比较。这个兜底成本极低加一行就能免疫环境差异我在任何通用排序器里都会带上它。2. 比较函数是所有问题的源头写得好的排序器长得都差不多出问题的排序器各有各的写法。要在几十上百个元素里定位一次排序错误光靠看结果是不够的得先理解引擎是怎么消费这个比较函数的。2.1 返回值的三态语义与符号约定比较函数的返回值不是布尔答案而是方向信号。规范里它会被ToNumber转换成一个数字然后按符号判断负数表示第一个参数排前面正数表示第二个参数排前面零表示两者等价。注意是符号不是具体数值所以返回-100和返回-1效果完全一样返回0.5和返回1也一样。这套语义带来一个非常隐蔽的坑返回布尔值。(a, b) a.age b.age返回的是true或false转换后是1或0。也就是说这个函数永远返回非负数永远不会告诉引擎a 应该在 b 前面。结果就是排序看着像做了点什么其实是乱的而且不同引擎上的乱法还不一样非常难排查。同理返回字符串1会被转成 1看着能跑实际是运气。返回值写法实际被转换成的数字引擎理解的含义结论a - b数字差值按差值符号判断正确a btrue→ 1永远a 在后错误结果不可预测a - b字符串数字差值数字正确能跑但脆弱建议显式转数字a.localeCompare(b)-1 / 0 / 1正确正确字符串场景首选return a b0 或 1相等判断被曲解错误注意a - b只在两边都是数字时可靠。一旦有undefined结果是NaNNaN参与符号判断后比较函数返回值被当成 0 处理这一对元素就相等了排序结果会莫名其妙地错位而且不报错。2.2 原地排序、稳定性与索引兜底sort修改原数组这一点在数据流清晰的场景下不算问题但在三种情况下必须警惕。一是源数据来自接口缓存的排一次序把缓存改了下次请求命中缓存拿到的是排过序的版本二是同一个数组被多个组件引用A 组件排序导致 B 组件列表顺序跟着变三是不可变数据流里引用没变导致更新被跳过。处理办法很朴素排序器入口先const next list.slice()内部只操作next。代价是一次 O(n) 的浅拷贝对于几千条以内的数据完全无感。如果确实在意这点内存可以只在检测到调用方要求不可变时才拷贝但我不建议为这点性能加分支可读性更重要。稳定性兜底则是另一个习惯问题。规范的稳定排序只保证原始顺序被保留而原始顺序在你不拷贝数组的情况下可能已经被上一次排序污染了。所以我会在装饰阶段给每个元素记一个自增下标比较到最后都相等时用下标做最终裁决。这样即使数据源本身的顺序变过本次排序的结果也是确定的、可复现的——这一点在多列排序时特别重要用户来回点表头时不会看到元素在相等组里乱跳。2.3 比较函数写错的五种典型形态第一种是返回布尔值前面说过。第二种是忘记处理空值null - 1得-1看着能比但null和0会被判成相等而undefined - 1得NaN直接乱。第三种是把数字字符串当数字用减号10 - 9得 1能跑但只要有个字段是10 件-9 件就是NaN整列排序崩掉。第四种是在比较函数里做副作用比如计数、写日志、修改元素这些操作会被调用 O(n log n) 次顺序还不固定调试时看到日志数量对不上会怀疑人生。第五种最隐蔽比较函数不满足传递性。举个真实例子有人按是否有货排序写的逻辑是有货的排前面否则比较价格。这看起来合理但当 a 有货、b 无货、c 有货且价格不同时a 和 b 的关系由有货决定b 和 c 也一样a 和 c 却由价格决定。这种不一致会让排序算法陷入逻辑矛盾结果既不是稳定排序也不是可预测排序部分引擎在检测到比较函数行为异常时会直接抛错多数情况下则是安静地给你一个错误结果。3. 一个能直接用的通用排序器聊完原理进入正题。我把我平时用的排序器拆成三层取值层负责从对象里把 key 对应的值拿出来归一化层负责把脏数据压成可比值比较层负责在三态语义下给方向。分层的好处是每一层都能单独测试出问题时能快速定位到底是取错值还是比错了。3.1 需求清单与字段设计先列清楚这个排序器要支持什么单个字段和多个字段、升降序、数字/字符串/日期/布尔/自然排序五种类型、空值前置或后置、嵌套路径user.profile.age和函数取值、中文按拼音、数字字符串按数值、返回新数组不污染源数据、结果稳定可复现。这十条基本上覆盖了业务里 95% 的排序需求剩下的边角需求可以通过自定义比较函数扩展。字段命名上我倾向用key、order、type、nulls、locale五个参数其中order取asc | descnulls取first | last。这里有个设计上的取舍空值的位置不受order影响。也就是说无论升序还是降序null都待在用户指定的那一头。原因是业务上空值通常代表没有数据用户不希望在切换升降序时看到一片空白在两端跳来跳去。3.2 取值层路径解析与 getter取值层要解决两个问题一是a.b.c这种深层路径二是arr[0].name这种带下标的路径。为了不引入额外依赖我用最简单的split(.)逐段下钻遇到null或undefined就提前返回。这种写法不支持a[0]的方括号语法但可以通过数组形式传路径解决[list, 0, name]。function readPath(target, path) { if (typeof path function) return path(target); if (path null || path undefined) return target; const segs Array.isArray(path) ? path : String(path).split(.); let cur target; for (let i 0; i segs.length; i 1) { if (cur null || cur undefined) return undefined; cur cur[segs[i]]; } return cur; }之所以支持函数取值是因为有些字段需要现算比如下单距今小时数这种派生值。传函数的好处是排序器不用知道计算逻辑坏处是函数会在排序过程中被反复调用这一点在第 5 章讲性能时会再提。如果这个函数开销大装饰法能把它从 O(n log n) 次调用降到 O(n) 次。3.3 归一化层把脏数据压成可比值归一化是这个排序器最值钱的部分。它把可能是null、可能是数字字符串、可能是 Date 对象、可能是布尔的一堆脏值统一成{ blank, value }两个字段。blank标记是否需要走空值分支value是已经转好类型的可比值。function isBlank(v) { if (v null || v undefined) return true; if (typeof v number Number.isNaN(v)) return true; if (typeof v string v.trim() ) return true; return false; } function normalizeValue(raw, opt) { if (isBlank(raw)) return { blank: true, value: raw }; const type opt.type || auto; if (type number) { const n Number(raw); return Number.isNaN(n) ? { blank: true, value: raw } : { blank: false, value: n }; } if (type date) { const t raw instanceof Date ? raw.getTime() : new Date(raw).getTime(); return Number.isNaN(t) ? { blank: true, value: raw } : { blank: false, value: t }; } if (type boolean) { return { blank: false, value: raw ? 1 : 0 }; } if (type string || type natural) { return { blank: false, value: String(raw) }; } // auto 模式 if (raw instanceof Date) return { blank: false, value: raw.getTime() }; if (typeof raw number) return { blank: false, value: raw }; if (typeof raw boolean) return { blank: false, value: raw ? 1 : 0 }; if (typeof raw string raw.trim() ! !Number.isNaN(Number(raw))) { return { blank: false, value: Number(raw) }; } return { blank: false, value: String(raw) }; }这里有个刻意的设计auto模式下纯数字字符串会被当成数字。业务里非常常见的情况是接口把金额、数量返回成128这样的字符串如果按字符串比9会排在128后面。这个转换的代价是丢失字符串语义比如007和7会被判相等。如果确实需要保留比如订单号、编号调用时显式指定type: string就行。我在函数注释里写清楚这条规则比让人去猜要好。3.4 比较层与完整实现比较层要考虑的是两个已归一化的值怎么比。数字和日期直接比大小布尔转成 0/1 后比字符串交给Intl.Collator。用 Collator 而不是直接a b是因为前者支持中文按拼音、数字按数值、大小写忽略而这些恰好是业务里最常要的三种行为。同时 Collator 实例是可以通过缓存复用的反复new会拖慢排序。const collatorCache new Map(); function getCollator(locale, numeric, sensitivity) { const cacheKey locale | numeric | sensitivity; let c collatorCache.get(cacheKey); if (!c) { c new Intl.Collator(locale, { numeric, sensitivity }); collatorCache.set(cacheKey, c); } return c; } function compareValues(a, b, opt) { if (typeof a number typeof b number) { return a b ? -1 : a b ? 1 : 0; } return opt._collator.compare(String(a), String(b)); }注意这里我用a b ? -1 : a b ? 1 : 0而不是a - b。原因有两个一是两边是Infinity或超出安全整数范围时相减会得到Infinity或NaN符号判断就不可靠了二是三元式写法对任何可比较类型都成立包括字符串统一写法能减少脑力负担。性能上现代引擎对这两种写法的优化差别可以忽略。把三层拼起来完整排序器长这样function createSorter(options) { const configs (Array.isArray(options) ? options : [options]).map((o) { const cfg { key: null, order: asc, type: auto, nulls: last, locale: zh-Hans-CN, sensitivity: variant, numeric: false, ...o, }; cfg._collator getCollator( cfg.locale, cfg.type natural || cfg.numeric true, cfg.sensitivity ); return cfg; }); return function sortList(list) { const n list.length; const decorated new Array(n); for (let i 0; i n; i 1) { const item list[i]; const keys new Array(configs.length); for (let f 0; f configs.length; f 1) { keys[f] normalizeValue(readPath(item, configs[f].key), configs[f]); } decorated[i] { item, index: i, keys }; } decorated.sort((x, y) { for (let f 0; f configs.length; f 1) { const cfg configs[f]; const ka x.keys[f]; const kb y.keys[f]; if (ka.blank || kb.blank) { if (ka.blank kb.blank) continue; const dir cfg.nulls first ? -1 : 1; return ka.blank ? dir : -dir; } let r compareValues(ka.value, kb.value, cfg); if (r 0) continue; if (cfg.order desc) r -r; return r; } return x.index - y.index; }); const result new Array(n); for (let i 0; i n; i 1) result[i] decorated[i].item; return result; }; }用法很直接const sortBy createSorter({ key: amount, type: number, order: desc }); const rows [ { id: 3, name: 报表A, amount: 128 }, { id: 1, name: 报表B, amount: null }, { id: 2, name: 报表C, amount: 96.5 }, ]; console.log(sortBy(rows).map((r) r.id)); // [3, 2, 1] console.log(rows.map((r) r.id)); // [3, 1, 2]原数组未变3.5 多字段排序的优先级组合多字段就是传数组配置的先后顺序即为优先级。前面提到的成绩单例子规则可以写成const sortScore createSorter([ { key: total, type: number, order: desc }, { key: chinese, type: number, order: desc }, { key: studentNo, type: string, order: asc }, ]);这套配置的可读性比嵌套三元强太多加维度就在数组末尾加一项删维度就删一项。有个细节值得说每个字段可以有自己的type和nulls。比如金额字段空值希望排最后备注字段空值希望排最前这在单比较函数的写法里几乎不可能干净地实现。提示多字段排序时尽量把区分度高的字段放前面。比如按省份城市排序放省份在前能更快减少比较次数虽然对n log n的整体复杂度没影响但在实际数据里能减少后续字段的比较调用。4. 各类数据类型的排序细节通用排序器搭好之后真正的麻烦在于每种类型都有自己的边界情况。这一章我把常见的几类值单独拎出来讲都是实际项目里遇到过的。4.1 数字、数字字符串与 NaN数字排序看似最简单其实坑不少。第一是数字字符串混排如果数据里既有10又有9不做归一化的话9会被当成字符串走 Collator 路径结果9排在10后面——这恰好符合预期但如果两边都是字符串10和9普通字符串比较会得出10 9就错了。所以数字字段的类型声明一定要显式不要指望auto兜住所有情况。第二是NaN 和 Infinity。NaN在比较里永远是 falseNaN - 1是NaNNaN 0也是 false所以任何含NaN的比较都会得出相等。我在归一化层把NaN直接判定为blank让它走空值分支这样它就有一个确定的位置而不是随机漂在中间。Infinity则相反它能正常参与比较Infinity 任何有限数成立所以不需要特殊处理只要不用相减法就行。第三是精度问题。浮点数比较用和是安全的因为它比的是实际存储的二进制值。但如果你的业务需要按显示精度排序比如都保留两位小数后比较那得在归一化阶段就做Math.round(x * 100) / 100否则1.004和1.005的相对顺序和用户看到的1.00 / 1.01会对不上用户会觉得排序坏了。4.2 字符串、大小写与中文字符串排序的默认行为是按 UTF-16 码元比较这会导致三个结果大写字母全排在小写前面Z a、数字字符排在字母前面、中文按 Unicode 码点排而不是按拼音。业务里几乎没人想要这种顺序所以要换成localeCompare或Intl.Collator。Collator 的sensitivity参数控制比较时忽略什么。默认variant是全量比较base只比字母本身忽略大小写和重音accent忽略大小写保留重音case忽略重音保留大小写。做通讯录、客户名列表这类需求我一般用base因为用户不关心张三和张三在大小写上有啥区别他们只想要一个符合直觉的顺序。中文场景下locale传zh-Hans-CN能得到按拼音排序的效果。这里有个我在容器环境里真实踩过的坑不同运行环境内置的语言数据完整度不一样。完整的语言数据包能正确按拼音排精简版可能退化成按码点排于是同一份代码在开发机和线上容器的排序结果不同。排查这类问题的方法是打印new Intl.Collator(zh-Hans-CN).resolvedOptions().locale看它实际用了什么如果返回的不是你传的值说明环境降级了。稳妥的做法是排序结果不要在前后端各算一遍然后比对选一边算就够了。4.3 日期、布尔与自定义枚举日期排序的关键是统一成时间戳。如果数据里混着Date对象、ISO 字符串、2024/03/01这种斜杠格式直接比字符串会出错因为2024/03/01和2024-03-01的字符顺序不同。归一化时统一走new Date(x).getTime()拿不到有效时间戳就当空值处理。注意new Date(2024-13-01)得到的是Invalid DategetTime()返回NaN正好被空值分支接住。布尔排序的语义要看业务。如果是是否启用那true排前面更符合直觉如果是是否删除那false排前面更合理。我的做法是归一化时把布尔转成 0/1然后由调用方用order控制方向而不是在排序器里内置语义。这样同一个字段在不同页面可以有不同排法不用改排序器。自定义枚举状态、优先级是另一个常见需求。它们的正确顺序往往不是字典序而是业务定义的顺序比如待付款 → 已付款 → 已发货 → 已完成。这种场景下别硬扛直接传一个权重映射函数作为keyconst STATUS_WEIGHT { pending: 1, paid: 2, shipped: 3, done: 4 }; const sortByStatus createSorter({ key: (row) STATUS_WEIGHT[row.status] ?? 99, type: number, order: asc, });把不可比的状态映射成可比的数字是所有枚举排序的通用解法。?? 99是为了让未知状态沉底而不是变成undefined参与比较。4.4 自然排序版本号、文件名、章节号自然排序指的是人眼看过去的顺序典型例子是v1.9.0要排在v1.10.0前面第2章要排在第10章前面。普通字符串比较会把10排在2前面因为它逐字符比1 2。手写自然排序需要把字符串切成文本段数字段再逐段比代码量不小还容易在连续数字、前导零这些细节上翻车。Intl.Collator的numeric: true就是干这个的一行搞定const naturalCompare new Intl.Collator(zh-Hans-CN, { numeric: true }).compare; [v1.10.0, v1.9.0, v1.2.0].sort(naturalCompare); // [v1.2.0, v1.9.0, v1.10.0]在我的排序器里type: natural就会打开这个开关。要提醒的是numeric: true处理1.10和1.9这类带多级小数的版本号时行为是按数字段拆分的1.10会被看成1、10两段1.9看成1、9于是1.9 1.10这符合版本号语义但不符合数值语义。如果字段本身是小数就别用natural老老实实用number。4.5 空值null / undefined / 空串 的归位空值处理是排序器好不好用的分水岭。三种空值在业务上的语义其实不同null通常是该字段没有值undefined是字段不存在空串是用户没填。但排序时没必要区分这么细统一按blank处理位置由nulls参数控制。需要特别注意的是null和0的区别。null - 1得-1看起来像null 比 1 小于是null混在数字里排到了最前面用户看到金额列最上面一堆空白。这就是没走空值分支的典型后果。同理Number()得0如果先把空串转数字再比所有空串都会变成 0 参与排序位置完全不可控。所以归一化必须先判空再转类型顺序不能反。另外一个实践细节nulls: first和nulls: last我建议做成全局配置而不是每个字段单独配。原因是一张表里多个字段的空值位置不一致视觉上会很乱用户反而困惑。除非业务有明确要求否则统一空值沉底是最不容易被投诉的选择。5. 性能与工程落地排序的性能问题很少来自算法本身——n log n就是那么回事——大多来自比较函数里做的事太多和在不该排序的地方排序。5.1 装饰-排序-去装饰 什么时候值得上面那个createSorter其实已经是装饰法的变体先把每个元素的所有 key 值算好存起来再排序最后还原。这个模式在算法教材里叫 Schwartzian transform直译过来就是装饰-排序-去装饰。它解决的痛点很具体如果key是个函数里面做了字符串拼接、正则匹配、日期格式化这类耗时操作那在比较函数里现算就意味着 O(n log n) 次计算而 1000 条数据大概要比较 10000 次左右每条算两遍就是两万次。装饰法把它降到 O(n) 次也就是 1000 次差了一个数量级。数据量越大、取值逻辑越重收益越明显。代价是多一次数组遍历和一份临时对象的内存。对于万级以上数据这份临时对象的开销可能比省下来的计算还大。所以我的取舍是取值是简单属性访问时用直接比较取值涉及计算时用装饰法。前面给的实现默认走装饰是因为它的适用面更广而多数业务的单次取值成本都不高多占的那点内存可以接受。方案比较函数调用次数取值次数适用场景注意点直接比较a[key] - b[key]O(n log n)O(n log n) × 2一次性、简单字段、数据量小不改动源数组需先 slice装饰法推荐O(n log n)O(n)复杂取值、函数 key、多字段多一份临时内存后端返回有序数据00分页数据、超大数据量翻页时排序必须由服务端保证5.2 数据量分级与策略选择我把数据量大致分三档对应三种策略。千条以内随便怎么排都行前端排完再渲染用户感知不到延迟。万条到十万条要注意取值成本和渲染成本排序本身几十毫秒能完成但如果有大量 DOM 节点渲染才是瓶颈这时候虚拟滚动比优化排序更有用。百万级以上前端排序基本不可行了内存和 GC 都扛不住应该交给服务端。还有一个容易被忽略的场景分页列表的前端排序。如果每页 20 条用户在第 1 页点排序只能对这 20 条排序翻到第 2 页又是另外 20 条各自排序用户会觉得排序是假的。这种情况下前端排序只能用于当前页内重排这种局部需求全局排序必须带参数请求服务端。这条经验我在评审里强调过很多次但每隔一段时间还是能看到实现错的地方。5.3 框架里的响应式与不可变陷阱在主流前端框架的响应式系统里排序有两个高频陷阱。第一个是原地排序不触发更新this.list.sort(cmp)之后和排序前是同一个数组引用依赖引用比对的更新机制认为数据没变视图不刷新。解决办法就是排序器返回新数组或者写成this.list [...this.list].sort(cmp)。第二个是在模板或计算属性里直接排序。如果每次渲染都调一次排序虽然结果一样但会反复创建新数组触发无限更新循环或者性能雪崩。正确做法是把排序结果缓存到计算属性或者独立的 state 里依赖变化时才重算。同理别在render函数里写list.sort()那是原地修改等于每次渲染都把数据搅一遍。第三个是排序状态和选中状态打架。表格里勾选了某几行然后点排序如果勾选用的是数组下标做 key排完序勾选就串行了。这类问题表面看是排序造成的实际是用下标做标识的设计缺陷。修法是把排序结果和原始数据建立 id 映射勾选状态永远跟着 id 走。6. 常见问题速查与排查手法排序出错时最快的定位方式不是读代码是缩小范围。下面这张表是我这几年攒下来的高频问题和对应原因遇到相似症状可以直接对号入座。6.1 高频问题速查表症状最可能的原因排查动作修复方式数字列排成 1, 10, 100, 2用了默认 sort 或按字符串比确认比较函数是否存在、字段是否被转成字符串显式type: number排序看起来生效了但顺序仍乱比较函数返回布尔值在比较函数里打印返回值类型改成返回 -1/0/1 或差值null 全跑到最前没做空值分支null参与运算检查归一化顺序先判空再转类型点两次箭头顺序不一致缺稳定性兜底或源数组已被改复制源数组再排一次比对加索引兜底 返回新数组排序后视图没刷新原地排序引用未变打印排序前后结果返回新数组中文顺序和拼音对不上用了默认字符串比较或环境语言数据不全打印 Collator 的resolvedOptions指定 locale必要时换环境版本号 1.10 排在 1.9 前面按小数或字符串处理检查 type 是否设成 natural用numeric: true排序偶发卡死或结果诡异比较函数不满足传递性检查是否有多套判断逻辑混用统一成单一维度的比较分页后排序只影响当前页前端只排了当前页数据看数据来源是全部还是当前页排序参数传给服务端6.2 三个亲测好用的调试手法手法一把比较函数变成可观测的。出问题时先在比较函数里加一段只在开发环境生效的日志打印参与比较的两个值与返回结果。注意日志会刷屏所以要加条件比如只在前 20 次调用时打印。看一下前几对比较的值往往立刻就能发现类型不对或者值是 undefined。手法二断言单调性。排序完以后用一次 O(n) 遍历检查结果是否真的有序对每个相邻元素对调用同一个比较函数如果出现正数说明没排好。这个检查可以直接写成开发环境的断言只在dev模式跑function assertSorted(list, cmp) { for (let i 1; i list.length; i 1) { if (cmp(list[i - 1], list[i]) 0) { console.warn(排序未生效位置, i, list[i - 1], list[i]); return false; } } return true; }它比肉眼检查靠谱得多尤其是几百条数据的时候。我在几个通用工具库里都留了这个断言开发阶段默认开启上线时关掉。手法三二分定位脏数据。如果数组大部分排得对只有个别位置错乱说明脏数据就藏在附近。取出一段数据比如出错位置前后各 5 条单独对这 10 条跑排序看能不能复现能复现就继续二分通常三轮之内就能定位到具体哪条记录的哪个字段有问题。这比在整个数组上打断点高效太多。注意调试排序时别在浏览器控制台直接展开大数组看控制台的展示顺序和实际数组顺序有时不一致尤其是对象数组。用map抽出关键字段打印成字符串或者用JSON.stringify输出才看得准。7. 几句实操之后的体会这套排序器我在好几个项目里用下来最大的感受是排序的复杂度从来不在于算法而在于数据本身有多脏。同样是按金额排序接口稳定的时候三行代码搞定接口开始返回、--、暂无的时候就得靠归一化层把这些值一个个接住。所以我现在写排序器比较函数往往只有二十行归一化和空值处理占了八成代码量而后者才是真正省心的部分。另外一个建议是别急着造轮子。如果项目里已经有lodash之类的工具库sortBy能覆盖大部分简单场景没必要为了纯原生重写一遍。原生实现的价值在于你能完全掌控行为——空值去哪、中文怎么比、相等元素怎么排——这些在通用库里要么不可配置要么配置起来比手写还麻烦。什么时候该自己写判断标准很简单当你需要向别人解释为什么这个元素排在这里的时候就值得自己写一个了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →