JavaScript 数组排序:10 为什么排在 2 前面,原数组又是谁改的

10-01 3阅读

一次排序里,其实藏着两个独立决定

把一组数字交给 sort,结果可能让十排在二前面;把返回值放进新变量后,原数组也已经变了。这不是同一种错误:前者来自默认比较方式,后者来自方法会修改接收数组。修复排序前,要同时回答按什么顺序排,以及是否允许改变原来的数组。

普通数组默认排序会把需要比较的元素转为字符串,再按字符串顺序比较,因此数字外观不等于数值顺序。若数据本来就是有限数字,应提供数值比较器。若数据仍是用户输入文本,还需要先验证和转换,排序本身不承担格式校验。

把比较规则与复制策略分别做成断言

下面保存为 JavaScript 文件,用支持 toSorted 的 Node 运行,本文在二十四版本验证。样例先复现默认顺序,再验证 sort 返回原数组本身,并对照展开后排序和 toSorted 两种保留原数组顺序的方式。所有输入都是内存数组,不访问外部数据。

对象样例让两个任务具有相同优先级,检查排序稳定性,同时修改排序结果中的一个对象字段,观察原数组中对应对象的变化。最后的包装函数只接受由有限数字组成的稠密数组,明确拒绝字符串、NaN、无穷大和缺失索引,避免把输入清理藏进比较器。

JavaScript 数组排序:10 为什么排在 2 前面,原数组又是谁改的

AI概念配图,非真实界面

const assert = require('node:assert/strict');

const original = [2, 10, -3, 2];
assert.deepEqual([...original].sort(), [-3, 10, 2, 2]);
const changed = [...original];
const returned = changed.sort((a, b) => a - b);
assert.equal(returned, changed);
assert.deepEqual(changed, [-3, 2, 2, 10]);
const copied = original.toSorted((a, b) => a - b);
assert.notEqual(copied, original);
assert.deepEqual(copied, [-3, 2, 2, 10]);
assert.deepEqual(original, [2, 10, -3, 2]);
console.log('default: -3,10,2,2; numeric: -3,2,2,10');

const tasks = [
  {id: 'first', priority: 2},
  {id: 'second', priority: 1},
  {id: 'third', priority: 2}
];
const ranked = tasks.toSorted((a, b) => a.priority - b.priority);
assert.deepEqual(ranked.map(x => x.id), ['second', 'first', 'third']);
assert.deepEqual(tasks.map(x => x.id), ['first', 'second', 'third']);
assert.equal(ranked[0], tasks[1]);
ranked[0].id = 'edited';
assert.equal(tasks[1].id, 'edited');
console.log('stable ties preserved; copied arrays still share objects');

function sortedFiniteNumbers(values) {
  if (!Array.isArray(values)) throw new TypeError('expected array');
  for (let i = 0; i < values.length; i += 1) {
    if (!Object.hasOwn(values, i) || !Number.isFinite(values[i])) {
      throw new TypeError('expected dense finite numbers');
    }
  }
  return values.toSorted((a, b) => a - b);
}
assert.deepEqual(sortedFiniteNumbers([]), []);
assert.deepEqual(sortedFiniteNumbers([0, -2, 0]), [-2, 0, 0]);
for (const bad of [[1, '2'], [NaN], [Infinity], new Array(2), null]) {
  assert.throws(() => sortedFiniteNumbers(bad), TypeError);
}
console.log('copy, finite-number and dense-input checks passed');

比较器需要表达小于、相等和大于

数值升序比较器 a - b 用负数、零和正数表达相对顺序,不要求结果恰好是负一或正一。把它写成 a > b 则只返回布尔值,转换后只有零或一,不能完整表达反方向关系。对这种不一致比较器,不应根据某次碰巧正确的输出推断它可靠。

比较器应保持稳定的比较规则,不在比较过程中修改数组或依赖不断变化的外部状态。排序实现可以用不同次序、多次调用它,因此不能把比较器当作每个元素只执行一次的处理步骤。需要计算的字段最好提前准备,并先验证它们属于约定的数据范围。

复制数组,只隔开排列,不会复制对象内部

sort 修改原数组并返回同一引用,另一个变量指向它时也会看到新顺序。展开后再 sort 先生成新数组,toSorted 则直接返回排序副本;对于本文的稠密数字数组,两种方式都能保留来源顺序。旧运行环境没有 toSorted 时,需要确认兼容方案。

对象数组采用浅层复制,副本中的元素仍是原对象。代码修改排序副本中任务的名称后,原数组对应任务也随之改变。这里保住的是数组的排列结构,而不是每个任务内容的独立副本;若后面要编辑对象,应按业务需要另行复制目标字段或记录。

相同优先级的两个任务保持输入时的先后顺序,这是稳定排序的作用。不过,稳定性只能保留当前输入顺序,如果上游每次返回的顺序不同,输出仍可能不同。需要跨次运行都一致时,应加入明确的次级排序键,例如稳定编号,并写出相应比较规则。

用边界输入明确排序的适用范围

包装函数先验证每个索引都存在且值为有限数字,再返回排序副本。检查索引存在性是为了避免数组遍历方法跳过空洞,使不完整输入漏过校验。这里没有设计缺失值排到前面还是后面的策略,因此直接拒绝,比默默混入默认行为更容易解释。

验收时应同时核对结果顺序、来源是否改变、结果与来源是否同一数组,以及内部对象是否仍共享。再补上负数、重复值、空数组和非法输入,才能把数值比较与对象所有权一起说清楚。排序完成并不代表数据已被清洗,复制完成也不代表对象内容已隔离。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。