返回知识中心
指纹

数组排序与比较器:一个容易写错的函数

排序固定数组:默认的字符串顺序、就地修改、稳定排序、undefined 与 NaN 落在哪里、错误的比较器给出错误顺序,以及减法溢出。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的观察:页面把一个数组排成了什么顺序;但它不能改变运行时如何实现排序算法,不能把一个不一致的比较器变一致,也不会把排序结果变成身份信号。本文用默认比较器和显式比较器排序固定数组,然后记录为什么默认顺序是字符串顺序、为什么排序就地修改、稳定排序如何保持相等键的插入顺序、undefined 与 NaN 落在哪里、为什么不一致的比较器给出错误且无声的顺序,以及为什么减法比较器在大数上溢出。

摘要

  • 默认排序把每个元素转成字符串并按 UTF-16 码元排序,所以 [10,9,1,100].sort() 得到 [1,10,100,9],不是数字顺序。
  • sort 就地修改数组并返回同一引用,所以期望副本的调用者会静默改变源。
  • 数字比较器 (a, b) => a - b 得到 [1,9,10,100],读者期望的顺序,但减法比较器在差值超过双精度范围时溢出。
  • 排序是稳定的,所以比较相等的行保持插入顺序,对两个键按 2,1,2,1 分布来说是 bdac。
  • undefined 与空洞排到末尾;返回 NaN 的比较器对该对被视为 0,所以元素保持相对位置。
  • 一个不一致(非传递)的比较器可以产生错误顺序而不抛错,因为算法假定比较器是全序。
  • toSorted 返回副本并保持源不变,这是不修改地排序的方式。

目录

一个 10、9、1、100 的数组进入默认排序后输出为 1、10、100、9,因为每个元素变成字符串,而数字比较器一列输出为 1、9、10、100;STABLE 面板把两组相等键按插入顺序保持,一行注释标明 undefined 排到末尾、NaN 留在原位、减法比较器在超过 2 到 53 的范围后溢出

为什么默认顺序是字符串顺序

不带比较器的 Array.prototype.sort 不把数字当数字排。它把每个元素转成字符串并按 UTF-16 码元排序。探针用两种方式排同样四个数字:

Array.sort=function node=v26.9.0
[10,9,1,100].sort() -> [1,10,100,9]
[10,9,1,100].sort((a,b)=>a-b) -> [1,9,10,100]

默认排序返回 1, 10, 100, 9,因为作为字符串 "1" 在 "10" 之前、"10" 在 "100" 之前、"100" 在 "9" 之前(按码元):每个字符串的第一个字符决定,"9" 大于 "1",所以四个不以 9 结尾的数字排在前面。比较器形式返回 1, 9, 10, 100,读者期望的数字顺序。两次调用排同一个数组却完全不一致,只有一次符合"已排序"的直觉。

这个缺口是最常见的排序缺陷:开发者对数字数组调用 sort(),看到一个看似合理的结果,从不注意顺序是字典序的。它对一位数字和一位数字字符串看起来正确,一旦出现两位数值就坏了。修法是永远给数字传比较器,绝不依赖默认,因为默认是为字符串定义的,只在小输入上碰巧看起来正确。

字符串顺序也解释了为什么默认按字符串形式处理混合类型。探针把布尔、数字和字符串的混合数组一起排,顺序跟随每个元素的 String() 形式而不是任何按类型的规则。假设默认是"自然顺序"的人得到码元顺序,对 ASCII 字母它把大写排在前面、小写之后,对带重音的字母则排在所有 ASCII 字母之后。

探针用字符串和布尔的混合输入把码元顺序讲明白:

["b","a","C","á"].sort() -> ["C","a","b","á"]
[true,false,2,1].sort() -> [1,2,false,true]

四个字符串排成 "C"、"a"、"b"、"á",因为大写 C 的码元小于小写 a,带重音的 á 的码元大于任何普通 ASCII 字母,所以它排在最后。期望字母顺序的读者会把 "á" 放在 "a" 旁边,也许不期望 "C" 在 "a" 之前,但默认排序不是字母顺序,也从不声称是。布尔与数字的混合数组排成 1、2、false、true,因为作为字符串 "1" 和 "2" 在 "false" 和 "true" 之前,后两者首字母 f 和 t 的码元大于数字。

这个行为就是为什么对来自用户输入的标签排序不能用默认。只差大小写的两个标签按区分大小写的顺序排,读者不期望,而以数字开头的标签排在以字母开头的标签之前。识别 locale 的替代品 String.prototype.localeCompare 和 Intl.Collator 按语言规则而不是码元排序,所以读者感知为字母顺序的列表需要其中之一而不是默认。记录一份默认排序的标签列表,存的是码元顺序,可复现但不是读者会称为已排序的顺序。

这里出错的代价是一份稳定且可复现的列表,却在每个看它的人眼里读起来像没排序。一个用默认比较器排账户名的面板,把每个首字母大写的账户排到每个首字母小写的账户前面,并把带重音的名字放到整个 ASCII 区块之后,这不是扫描列表的操作员期望的顺序。顺序从不抛错,在同一引擎上跨运行不变,并静默地与意图不符,这是最糟的组合,因为一个只检查"是否排序"的测试抓住它,除非该测试编码了同样错误的规则。按默认比较器写的测试通过;按读者期望写的测试失败,两者在每个含混合大小写或带重音名字的列表上都分歧。

还有一个算术原因让默认顺序在相近的数值上出人意料。因为默认逐字符比较字符串形式,"2" 和 "20" 在第一个字符上比较并在那里打平,等第二个字符才决定,所以一组 id 2, 20, 3 作为字符串排成 2, 20, 3,但读者期望作为数字排成 2, 3, 20。共享前缀越长,决定越晚,文本顺序与数字顺序偏离越大,这就是为什么缺陷随着标识符增加位数而越来越明显。

默认比较器还决定数组内对象和数组的顺序,这是调用者很少看的地方。每个对象都转成字符串 "[object Object]",所以用默认比较器排普通对象数组会保持插入顺序,因为每个元素比较相等。数组的数组把每个内层数组转成其逗号连接的元素,所以外层排序按内层文本内容排,而不是按长度或任何字段。用默认比较器排记录列表并期望一个稳定、有意义的调用者,得到对象是插入顺序、嵌套数组是文本顺序,两者都不是心里的顺序。教训是默认比较器只对字符串形式恰好符合调用者期望的原始值数组有意义,这是一个混合或结构数组永不满足的狭窄情形。

默认比较器还有一个调用者可能不期望的性能形态。把每个元素转成字符串在最坏情况下每次比较都要为每个元素分配一次,而一个重复比较同一对的排序会重复这份工作,所以对一个大的数字数组做默认排序比做减法的比较器更慢。慢不是缺陷,但它是同一根源的症状:默认比较器是为比较字符串写的,一个数字数组穿过它为它不想要的顺序在每次比较上付出转字符串的代价。切换到数字比较器的代码同时得到正确顺序和更少的工作,这是正确版本也更快的一个罕见情形。

默认的字符串顺序还解释了为什么排一组写成字符串的数字一时看起来正确。由单个数字字符串 "1" 到 "9" 组成的列表排成数字顺序,因为每个字符串是一个字符,单个数字时码元顺序与数字顺序一致。缺陷只在某个值有两位时出现,因为那时 "10" 在第一个字符上排在 "2" 之前。由一位数值构建的 fixture 因此用默认排序能通过,而同一代码在含有十或更大值的生产数据上失败。通过 fixture 与失败生产用例之间的缺口完全在于值的位数,而排序从不上报这一点。

为什么排序就地修改

sort 不是其输入的纯函数:它重排被调用的数组并返回同一数组。探针排一个三元素数组并检查返回值是否与源是同一对象:

same reference -> true; array now [1,2,3]

返回的数组是输入的同一引用(result === arr 为 true),而源数组现在是 1, 2, 3。写 const sorted = arr.sort() 然后在别处使用 arr 的调用者已静默重排了 arr,因为 sorted 和 arr 是同一对象。在一个保留原始顺序用于显示又想要排序副本的代码里,这是真实缺陷,因为排序就地销毁了原始顺序。

不修改排序的方式是 toSorted,它返回新数组并保持源不变。探针确认差异:toSorted 之后源仍是 3, 1, 2,返回的数组是 1, 2, 3,两者是不同引用。既想要原始顺序又想要排序视图的调用者需要 toSorted 或在 sort 前显式 slice(),而不是对原始数组 sort。

这个修改也就是为什么渲染里的排序危险。如果组件为了显示而排一个共享状态数组,排序在每次渲染都修改状态,之后读状态看到的是排序顺序而不是加载时的顺序。修法相同:排一份副本,并记录排序碰了哪个数组,让读者知道观察到的顺序是存储顺序还是临时排序。

上面的 toSorted 示例已经是安全替代,只要原始顺序重要就能回本。既需要加载顺序又需要排序视图的人用 toSorted,或 slice() 再 sort,源保持加载时的样子。代价是一次分配,相对于一个在假定拥有顺序的组件下修改的状态数组的代价微不足道。

比较器里有一个相关陷阱,它按可能缺失的字段排序。对 key 为 undefined 的元素读 a.key - b.key 得到 NaN,排序把它当相等,于是元素保持相对位置而不是移到确定槽位。结果是一个缺失字段的行与它比较过的行交错在一起的顺序,一眼看去像排好了其实没有。修法是决定缺失字段属于哪里,用显式分支把它排到末尾,并记录这个决定,因为排序本身不会移动它。

这个就地修改也让人把排序与另一修改组合时不可靠。如果调用者排一个数组然后对第二个做 splice,且两者按引用共享元素,splice 读到排序留下的顺序,这可能不是调用者以为共享数组仍有的顺序。只要数组对象被共享,就地排序就是一次对共享内存的写,而一条不说明数组是共享、克隆还是新建的记录,无法知道后续读取看到的是排序顺序还是另一个代码重置前的顺序。

引用同一性也解释了为什么排序后的相等检查会误导。把排序数组与自身比较来证明"没变"的调用者发现相等,因为排序返回同一对象,尽管内容变了。想证明排序保持源不变的测试必须对比排序前取的副本,而不是同一引用,因为无论排序是否修改,引用相等都成立。toSorted 变体通过返回一个不同对象消除歧义,所以引用检查能区分"返回了副本"和"源被重写"。

就地修改还改变一个承诺顺序的变量名的含义。一个名为 insertionOrder 的常量传给 sort 之后再读,已不再保持插入顺序,因为排序重写了名字背后的数组。名字对内容撒谎,信任名字的读者在代码本想表达到达顺序处读到排序顺序。因为当返回值被忽略时修改在调用点不可见,读这个名字的代码里没有东西揭示顺序变了,一条用这个名字存储数组的记录,把排序顺序存进了一个声称相反内容的标签下。

最安全的模式只在消费排序结果的函数里排序,并在源在别处重要时把克隆传进该函数。一个接收数组并返回其排序顺序的函数,要么说明就地排序,要么返回 toSorted,调用者应一次决定源数组是否允许改变。把两者混在一起的代码,在一处就地排序、在另一处依赖原始顺序,产生一个依赖调用顺序的缺陷,这是最难复现的一类,因为只有当两个调用者按特定顺序运行时才失败。

修改还与浅拷贝以出人意料的方式交互,让以为拷贝就够的人吃惊。slice() 复制引用数组但不复制元素,所以按字段排拷贝只重排引用而不修改元素,原数组保持同一批引用的顺序。这对数组顺序安全,但如果排序随后写入元素以记录排序位置,写会到达共享元素,原数组的元素变了尽管它的顺序没变。"数组顺序变了"和"元素变了"的区别正是记录需要的区别,单独一个 sort 或 slice 都没说发生了哪个。

冻结数组是就地修改唯一大声失败的情形。在严格模式下排一个 Object.freeze 的数组会抛错而不是静默重排,这是修改唯一宣告自己是一次写的方式。想保证从不修改共享数组的代码可以在开发时冻结它,这样意外的 sort 会抛错而不是留下微妙的重排,然后在真正需要顺序处用 toSorted。冻结是把静默修改变成大声修改的廉价办法,和记录存下源是否允许改变是同一笔交易。

稳定排序在哪里保持相等键

排序是稳定的:比较相等的元素保持相对插入顺序。探针排两个不同键的四行并检查相等键行的顺序:

stable sort -> bdac

输入行是 a(键 2)、b(键 1)、c(键 2)、d(键 1)。按键升序排后顺序是 b, d, a, c:两个键 1 的行保持 b 在 d 之前,两个键 2 的行保持 a 在 c 之前。每个相等键组保留了插入顺序,这就是"稳定"的意思,也让调用者先按主键再按次键排序能得到期望的复合顺序。

稳定性并非一直被保证;旧引擎用不稳定算法,次键排序可能打乱主键。规范现在要求稳定排序,所以依赖它的代码在任何当前引擎上都正确。实际后果是"先按一列排,再按另一列排"的流程安全只因为排序稳定,而一条只存一次排序结果的记录,若不也存相等键行的排序前顺序,就无法区分稳定与不稳定结果。

稳定顺序也让多键比较器在许多情况下不必要。不必写一个先比 A 再比 B 的比较器,调用者可以先按 B 再按 A 排序,靠稳定性在 A 值相等时保持 B 顺序。两种方法在比较器一致时给出相同顺序,两趟版本更易读但只因为排序稳定才正确。

稳定性保证还决定一个"按列排序"的表格在第二次点击后能承诺什么。表格先按一列再按另一列排序,靠稳定性在第二列内保持第一列顺序,如果引擎不稳定,第二次点击会打乱第一列顺序,表格显示一个没有任何列头能描述的毫无意义的顺序。因为规范现在要求稳定,复合顺序在任何当前引擎上都成立,但一条只存最终顺序而不存排序序列的记录,无法证明复合顺序是按表格声称的方式导出的。

对一个稳定但不一致的比较器有一个更微妙的后果。稳定排序保持比较器称为相等的元素的顺序,所以一个错误地把两个不等元素称为相等的比较器会按它们到达的顺序保持它们,看上去合理却错了。稳定性让错误顺序显得是有意的,因为稳定排序从不打乱相等元素,所以缺陷就藏在让多键排序运作的同一保证背后。一条捕获比较器可能打平的元素的排序前顺序的记录,是区分正确复合排序与坏比较器上稳定排序的唯一办法。

稳定性还决定对已排序数据排序是否是空操作。一个先 A 后 B 的两趟排序,跑在已经按 A 排序的数据上,在 B 值相等时保持 A 顺序不变,所以重跑同样的两趟什么都不变。一个只读 B 的单一比较器,跑在同样数据上,按 B 重排行并让 A 的平局落入 B 顺序,这与两趟版本结果不同,尽管两者都"按 B 排序"。哪个正确取决于调用者是否想要次键,而稳定排序让两趟版本意味着它看起来的意思。

对记录来说,相等键的排序前顺序是把稳定结果变成可复现结果的字段。最终的排序数组本身不说两个相等键元素哪个先来,因为两者都是同一输入上稳定排序的合法结果,取决于到达顺序。只有排序前顺序加比较器加稳定性保证才能钉住结果,而一条只存最终数组不存排序前顺序的记录,存了一个合法结果并称之为结果。

稳定性还影响跟随排序的去重。排一个数组然后移除相邻重复以保留每段"第一个"的代码,靠稳定性把每段最先到达的元素放前面。在稳定引擎上最先到达的元素赢得去重,在不稳定引擎上可能是另一个,从而改变幸存的值。因为排序现在稳定,最先到达的元素总是幸存,而一条只存去重结果不存排序前顺序的记录,说不出保留了哪个元素以及为什么。

两趟复合排序有一个单一比较器隐藏的成本。排两次运行算法两次,第二趟按主键重排整个数组,而稳定性在平局内保持次键顺序。一个先比主键再比次键的单一比较器一趟完成并给出同样结果,但它必须自己处理次键比较并在它现在比较的三个字段上保持一致。无论代码选哪种方法,记录都必须说明用了哪个,因为同数据上的两趟结果与一趟结果只有在比较器是复合键上的一致全序时才一致。

稳定性还让分页排序安全。返回排序列表第二页的服务器靠稳定顺序,让第二页的行在按排序键打平时不重复第一页的行。没有稳定性,平局顺序每页任意,一行可能出现在两页或都不出现,这是没有任何错误会报告的经典分页 bug。稳定排序让平局顺序在一次遍历内确定,但在两个独立请求之间,平局顺序取决于服务器读行的顺序,所以客户端的稳定排序救不了一个服务器用不稳定顺序构建的页。记录必须捕获哪一侧排序以及那一侧是否稳定。

undefined 与 NaN 落在哪里

undefined、数组空洞和 NaN 在排序下不像普通数字,探针显示每一个去哪里:

[3,undefined,1].sort() -> [1,3,null]
[3,undefined,1].sort((a,b)=>a-b) -> [1,3,null]
[1,null,3].sort((a,b)=>a-b) -> [null,1,3]
[3,NaN,1].sort((a,b)=>a-b) -> [3,null,1]

前两行打印的 null 是序列化器的视图:JSON.stringify 把一个持有 undefined 的数组槽(在一次把它推到最后后的排序之后)渲染成 null,因为数组在 JSON 中不能持有 undefined。存储的值是末尾的 undefined,排序把它推到那里,因为默认排序把 undefined 当作大于任何已定义元素并总是把它排最后。比较器形式做同样的事,因为规范无论比较器如何都把 undefined 送到末尾。

null 那一行是真实值:null 在 a - b 下比较为 0,所以 [1, null, 3] 排成 null, 1, 3,null 在最前面就像它是零。NaN 那一行最尖锐:NaN 与任何其他值比较返回 NaN,排序把它当 0,所以 NaN 相对它比较过的元素不移动,数组保持它的 3, NaN, 1 顺序。一个整趟排序都返回 NaN 的比较器对每对都被当作"相等",所以顺序被保留。

这两个行为就是为什么一条排序记录必须说明它是否持有 undefined、空洞或 NaN。一个静默含 NaN 的排序数字列表不会排成读者期望的数字顺序,因为 NaN 留在原地,其余围着它排。一条只存打印数组、NaN 已被序列化器变成 null 的记录,在不知道曾有 NaN 的情况下无法重跑到同一顺序。

NaN 那一行还改变排序如何与过滤组合。排完再过滤头元素的代码靠排序把一个已知元素放到最前;如果数组含一个留在原地的 NaN,头元素不是最小值,过滤器移除了错误的元素。NaN 留在插入处而排序把它留在那里,所以排序数组的头是排序真正移动过的元素中最小的,它可能不同于整个数组的最小值。一条注明 NaN 存在的记录让读者知道头元素不一定是那个最小值,而在排序前过滤的代码完全避开这个陷阱。

undefined、空洞和 NaN 的情形对一个 fixture 共享一个教训:按数组实际到达的样子测试,而不是按它在清理步骤后看起来的样子。一个预先排好清理数组的 fixture 通过,而同一代码在带尾随 undefined 或杂散 NaN 的数组上行为不同却不抛错。清理步骤恰好隐藏了排序特别处理的那个输入,一条存下清理数组的记录存下了排序当作普通的那一版,而不是生产路径喂给它的那一版。

undefined 与比较器的交互值得再走一步,因为它决定混合值上的比较器是否像默认一样。对已定义值和 undefined 做加减的比较器产生 NaN,而规范在比较器被咨询之前就把 undefined 送到末尾,所以那个对的算术从不运行。这意味着在普通循环里会对 undefined 失败的比较器在排序里是安全的,因为排序先移除 undefined 元素。在排序前把 undefined 从数组里预过滤的人,做了排序反正会做的工作,而把它们留下的人得到确定、可复现的结果而不是异常。

null 值既不像 undefined 也不像读者期望的数字。null 在减法下变成 0,所以比较器把它排到数字最前,而把 null 当"缺失"并期望它在末尾的人发现它在开头。"缺失为 undefined"和"缺失为 null"的区别因此改变排序结果,而一条只存打印数组、两者都渲染为 null 的记录,说不出源用了哪种表示。对存储形式重跑排序的人,每当两种表示混合时就得到与原始不同的顺序,因为一个排到末尾另一个排到最前。

为什么不一致的比较器给出错误顺序

排序算法假定比较器描述一个全序:如果它说 a 不大于 b、b 不大于 c,那么 a 不大于 c,且 a 与自身比较相等。打破这一点的比较器可以产生错误顺序而不抛错。探针用一个只比较奇偶的比较器排序,展示一个无害和一个坏掉的情形:

sort(()=>0) keeps order -> [3,1,2,5,4]
non-transitive comparator -> [2,4,6,8,10,1,3,5,7,9,11]

sort(() => 0) 说每对相等,所以算法把数组原样留下:3, 1, 2, 5, 4。这无害且可预测。非传递比较器 (a, b) => (a % 2) - (b % 2) 说每个偶数小于每个奇数,但它不一致:一个偶数、一个奇数、另一个偶数可以循环比较。结果 2, 4, 6, 8, 10, 1, 3, 5, 7, 9, 11 把所有偶数放前面、奇数放后面,但在奇数组内顺序 1, 3, 5, 7, 9, 11、偶数内 2, 4, 6, 8, 10 恰好是升序只因为输入如此;不同的输入可能让偶数被打乱,而什么都不抛。

一个返回大于两数之间双精度间隙的值的比较器有同样的失效模式,原因不同。探针用一个减法比较器排三个围绕 2^53 的值:

big subtract sort -> ["-9007199254740992","1","9007199254740992"]

2^53 是其以下每个整数都能被 double 精确表示的最大整数,比它大一的整数不可表示,所以 (2^53) - (2^53 + 1) 不能等于 -1;它舍入到 0,比较器把两个不等数报告为相等。减法比较器在这个范围上因此给出任意但稳定的顺序而不是数字顺序,失败是无声的。修法是用显式的小于和大于分支返回 -1、1 或 0,而不是相减。

溢出无声的方式和字符串顺序无声的方式一样,值得说为什么两者共享一个根源。两个缺陷都来自信任一个容易写却在输入某部分错误的比较:默认比较器信任数字的字符串形式,减法比较器信任差值放得进一个 double。两者都产生一个稳定、不抛错、对开发者测试的输入看起来排好了却在对重要的输入上偏离的顺序,所以一个只检查代码跑过的测试抓不住任何一个。两者的修法是让比较对真实域显式且完整,并记录域,让后来的读者知道比较器在哪些输入上验证过。

一致性要求和溢出要求在同一处相遇:比较器必须对它实际会看到的值定义一个全序。一个为数字写的比较器收到一个 NaN、一个 null、一个 undefined 或一个字符串,就不再是其输入上的全序,排序会产生一个严格比较测试在不抛错的情况下拒绝的顺序。补救是在排序前规范化输入,或写一个显式处理每种类型的比较器,然后记录处理了哪些类型,因为排序本身从不抱怨它无法信任的比较器。

记录一次排序读数

一份排序读数支撑一个很窄的断言:这个运行时、这个数组、用这个比较器,产生了这个顺序。六个字段让它可复核,且没有一个假定数组是数字的或比较器是一致的:

  1. 比较器,记录为源文本或"默认"这个选择,因为默认排序按字符串排,而任何显式比较器定义自己的顺序。
  2. 排序是修改了源数组还是返回了副本,因为 sort 就地修改而 toSorted 返回副本,一条没有此项的存储顺序无法归到正确的数组。
  3. 任何相等键组的排序前顺序,因为稳定排序保持它,而存储的结果本身不显示哪个相等元素在前。
  4. 数组是否持有 undefined、空洞或 NaN,因为它们排到末尾或留在原地而不是跟随数字顺序,序列化器把它们变成 null。
  5. 比较器是否为一致全序,因为非传递或溢出的比较器产生错误顺序而不抛错。
  6. 元素类型,因为混合数组在默认比较器下按每个元素的字符串形式排序,而不是按任何共享的数字顺序。

这样的读数不能显示的是服务器或第二个运行时会如何排列同样的数据。页面排自己的数组,但返回预排序列表的服务器可能用了不同比较器,客户端的稳定排序无法恢复服务器没发的顺序。关于序列化器给对象的键顺序,对象键顺序 展示了为什么整数键排在最前而字符串键保持插入顺序;关于副本保留的容器,structuredClone 与 JSON 展示了哪些类型在深拷贝后存活、JSON 往返会压塌什么。

两个习惯让读数随时间保持诚实。第一,对数字始终传显式比较器,并用小于和大于分支而不是减法来写,这样大差值不会舍入成相等。第二,记录排序是否修改了源以及排序前相等键的顺序,这样一份存储顺序可归属,一个稳定结果能与一个侥幸结果区分。

公开来源

结论

把比较器当作数据契约的一部分:默认排序按字符串排并把 [10,9,1,100] 变成 [1,10,100,9],所以始终传显式数字比较器,用小于和大于分支而不是减法来写,让超过 2^53 的差值不会舍入成相等。要知道 sort 就地修改而 toSorted 返回副本,排序稳定所以相等键保持插入顺序,undefined 与空洞排到末尾而 NaN 留在原地,以及一个不是一致全序的比较器给出错误且无声的结果。记录比较器、源是否修改、相等键的排序前顺序以及任何 undefined、空洞或 NaN,这样一份存储顺序可归属,一个稳定结果能与一个侥幸结果区分。

延伸阅读

关于序列化器给对象的键顺序,阅读 对象键顺序;关于哪些容器类型在运行任何排序之前的深拷贝中存活,阅读 structuredClone 与 JSON。

#Array.sort#比较器#稳定排序#NaN#整数溢出

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。