JS数组排序不修改原数组:使用扩展运算符或toSorted()方法

作者:拾光时间:2026-11-12 14:27:25浏览:124

在 JavaScript 中,原生 sort() 方法会原地修改原数组。本文详解如何利用 ES6 扩展运算符 [...arr].sort() 以及 ES2023 原生 toSorted() 方法实现非破坏性排序,剖析内存原理、代码示例、运行环境兼容性与浅拷贝避坑指南。

JavaScript / ECMAScript 数组操作 不可变数据 (Immutable) 发布分类:前端开发教程
在 JavaScript 中,直接对数组调用 arr.sort() 会在原内存空间上修改数组本身(原地排序),并返回原数组的引用。如果你需要保留原数组内容不变,业界主流有两种标准解决路径:一种是 ES6 兼容度最高的扩展运算符浅拷贝 [...arr].sort(),另一种是 ES2023 新增的原生非破坏性方法 arr.toSorted()。本文将具体阐明两者背后的运作机理、代码实现与选型场景。
JS数组排序不修改原数组:使用扩展运算符或toSorted()方法
图 0:JavaScript 非破坏性数组排序的核心路径概览

一、为什么原生的 sort() 会改变原数组

JavaScript 的 Array.prototype.sort() 自诞生起就被设计为一种原地操作(in-place)的方法。这意味着调用它时,排序算法直接在原数组所分配的堆内存槽位中重新排列元素,并不会分配新的数组内存。

我们可以通过一段极简的代码观察这种破坏性特征:

// 原生 sort() 的破坏性示例
const original = [40, 10, 30];
const returned = original.sort((a, b) => a - b);

console.log("原数组 original:", original);
console.log("返回值 returned:", returned);
console.log("引用是否同一:", original === returned);
示例运行输出
原数组 original: [ 10, 30, 40 ] 返回值 returned: [ 10, 30, 40 ] 引用是否同一: true

可以看到,original 本身的元素排列已经被彻底打乱,而且排序方法的返回值与原数组指向同一个堆内存地址。

在现代前端开发中(例如 React 的 useState、Redux 状态流或 Vue 的计算属性),原地修改数据往往是隐藏 Bug 的高发区。当你在组件内部直接调用 sort() 修改了 props 或 state 时,状态引用没有变化,不仅会导致组件无法正确响应更新,还可能污染全局或上层组件共享的数据源。因此,“排序时绝不改动原数组”是编写高质量纯函数与响应式代码的基本要求。

JS数组排序不修改原数组:使用扩展运算符或toSorted()方法
图 1:原生 sort() 原地修改与非破坏性排序内存模型对比

二、方案一:使用 ES6 扩展运算符 [...arr].sort()

在 ES2015(ES6)及以上环境中,最常用且最广泛兼容的做法是先使用数组扩展运算符(Spread Syntax)创建一份浅拷贝,再在拷贝出的新数组上调用 sort()。

1. 具体操作语法

// 使用扩展运算符实现非破坏性排序
const scores = [85, 92, 78, 99, 64];

// 步骤:先用 [...scores] 浅拷贝新数组,再调用 .sort()
const sortedScores = [...scores].sort((a, b) => a - b);

console.log("原数组 scores:", scores);
console.log("排序后副本 sortedScores:", sortedScores);
console.log("内存引用比较:", scores === sortedScores);
示例运行输出
原数组 scores: [ 85, 92, 78, 99, 64 ] 排序后副本 sortedScores: [ 64, 78, 85, 92, 99 ] 内存引用比较: false

2. 原理与执行推导

这行简洁的代码实际上经历了明确的三步演变:

  1. 解构浅拷贝:[...scores] 会在内存中申请一个全新的数组实例,并将原数组的第一层元素逐一拷贝进新容器中。
  2. 副本重排:此时链式调用的 .sort((a, b) => a - b) 虽然依然是原地修改,但它所修改的只是第一步创建出的临时新数组,原数组 scores 已经被完全隔离在操作范围之外。
  3. 安全交付:最终赋值给 sortedScores 的是经过排序的全新数组实例,两者的堆内存指针完全独立。
JS数组排序不修改原数组:使用扩展运算符或toSorted()方法
图 2:扩展运算符 [...arr].sort() 的三步执行推导流程

值得一提的是,在没有扩展运算符的早期代码中,开发者常用 arr.slice().sort() 或 [].concat(arr).sort()。这两种方式与 [...arr].sort() 的底层逻辑完全一致,都是通过创建浅拷贝来阻隔 sort() 的原地副作用。而在日常代码中,扩展运算符的书写最为直观、认知负荷最低。

三、方案二:使用 ES2023 新增的 toSorted() 方法

为了彻底解决数组变异方法(Mutating Methods)给函数式编程带来的不便,ECMAScript 2023(ES14)正式引入了 Change Array by Copy 规范提案,为数组增添了一组天然具备“非破坏性”的原生方法,其中专门用于排序的即为 Array.prototype.toSorted()。

1. 具体操作语法

// 使用 ES2023 原生 toSorted() 方法
const temperatures = [28, 19, 32, 25, 21];

// 直接调用 toSorted(),无需提前手动解构拷贝
const sortedTemps = temperatures.toSorted((a, b) => a - b);

console.log("原数组 temperatures:", temperatures);
console.log("排序结果 sortedTemps:", sortedTemps);
console.log("是否产生新实例:", temperatures !== sortedTemps);
示例运行输出
原数组 temperatures: [ 28, 19, 32, 25, 21 ] 排序结果 sortedTemps: [ 19, 21, 25, 28, 32 ] 是否产生新实例: true

2. toSorted() 的核心特性

与传统的 sort() 相比,toSorted() 的主要特质体现在:

  • 单步原子操作:不再需要 [...] 展开语法作为中间步骤,代码表达清晰直接,语义就是“返回此数组排序后的副本”。
  • 入参完全一致:toSorted() 接收与 sort() 相同的比较函数 compareFn。若传入相同的比较逻辑,两者输出的排列顺序严格一致。
  • 更易链式调用:在复杂的数据处理流中,可以直接 items.filter(...).toSorted(...).map(...),而无需在中间阶段用中括号打断链条。
JS数组排序不修改原数组:使用扩展运算符或toSorted()方法
图 3:ES2023 toSorted() 单步原生管道与传统组合方案对比

四、两种方案的关键差异与选型依据

读者在实际开发中可能会产生疑问:既然两种方式都能保护原数组,那么在日常项目中应该如何抉择?我们从运行环境、语法规范与底层行为三个维度进行比对。

特性维度 扩展运算符 [...arr].sort() 原生 toSorted() 方法
语言规范 ECMAScript 2015 (ES6) ECMAScript 2023 (ES14)
Node.js 支持 Node.js 6.0+(全版本覆盖) Node.js 20.0.0+(高版本支持)
主流浏览器支持 Chrome 46+, Safari 10+, Firefox 16+ Chrome 110+, Safari 16.4+, Firefox 115+
执行步骤 语法级解构浅拷贝 + 链式调用修改 引擎级单次内置“复制并排序”
旧环境适配 无需 Polyfill,直接运行 需要引入 core-js 或 Babel 插件补齐

选型建议:

  • 新项目或现代服务端(Node.js >= 20):建议优先采用 toSorted()。代码意图直接、无解构样板,符合现代 ECMAScript 编码规范。
  • 旧项目维护或需要兼顾旧版客户端/移动端 Webview:优先选用 [...arr].sort()。它是跨平台兼容性最好的通用模式,在任何支持 ES6 的环境中均能稳定工作。
JS数组排序不修改原数组:使用扩展运算符或toSorted()方法
图 4:技术选型决策树与避坑指南

五、排序操作的两大常见陷阱与注意事项

即便成功保护了原数组的顺序,在实际编码中仍有两处极易被忽视的陷阱,需要格外警惕:

陷阱 1:数字排序必须显式传入比较函数 (a, b) => a - b
无论使用 sort() 还是 toSorted(),如果不传递比较函数,JavaScript 默认会将数组各项转换为 UTF-16 字符串后比较字典顺序。例如:[100, 25, 5].toSorted() 的结果是 [100, 25, 5],因为字符 "1" 小于 "2","2" 小于 "5"。若要进行数值排序,必须始终明确书写 (a, b) => a - b(升序)或 (a, b) => b - a(降序)。
陷阱 2:两者的复制机制均属于“浅拷贝(Shallow Copy)”
如果数组的元素是对象类型(例如 [{ id: 1, name: 'Alice' }]),无论是扩展运算符还是 toSorted(),复制出来的都仅仅是对象的内存引用地址。虽然排序后数组内部的顺序是独立的,但若直接修改新数组中某个对象的属性(如 sortedList[0].name = 'Bob'),原数组中对应对象的属性同样会被同步修改。

如果你不仅需要重新排列顺序,还需要对对象内部属性做解耦隔离,应当结合深拷贝 API:

// 针对对象数组的深度安全排序示例
const users = [
  { id: 3, profile: { level: 2 } },
  { id: 1, profile: { level: 5 } },
  { id: 2, profile: { level: 1 } }
];

// 借助原生的 structuredClone 进行深拷贝后再排序
const safelySortedUsers = structuredClone(users).sort((a, b) => a.id - b.id);

// 修改新数组对象中的属性,原数组保持绝对隔离
safelySortedUsers[0].profile.level = 999;
console.log("原数组数据:", users[1].profile.level); // 输出 5,不受影响

六、总结

在 JavaScript 中保护原数组不受排序污染,本质上是在修改操作之前切断原有的内存引用关系:

  • 使用 [...arr].sort(compareFn):利用解构语法先在堆中复制出独立数组实例,再调用排序。兼容性极佳,适用于各类通用环境。
  • 使用 arr.toSorted(compareFn):利用 ES2023 官方引入的复制型方法,直接以表达式形式返回有序新数组。语义精确明了,是现代 JavaScript 开发的推荐标准。

在日常业务中,结合团队的基线运行时版本灵活选用这两种方案,并在数字排序与引用类型场景下牢记显式比较器与浅拷贝边界,即可从根源上杜绝数组原地修改引发的数据副作用。

作者最新文章
相关文章
更多