← 回到手记
代码

toSorted / with:别再原地 sort 改坏原数组了

ES2023 的 toSorted、toReversed、toSpliced、with 是 sort/reverse/splice/下标赋值的拷贝版:返回新数组,原数组不动;别再靠原地 sort 污染共享状态。

要解决的问题

前端里最常见的「一改全坏」之一,是原地改数组。列表页要按分数排序,随手写了 scores.sort(...);时间线要倒序,又写了 items.reverse();某一行状态要更新,直接 list[i] = next。这些写法在本地调试常常「看起来对」,但一旦数组来自 props、Redux/Zustand 状态、或被多个组件共享,原数组就被悄悄改掉了——UI 不刷新、缓存脏了、单元测试里「期望值」也对不上。

ES2023 起,数组有一组官方的「改副本」方法:toSorted、toReversed、toSpliced、with。它们分别对应 sort / reverse / splice / 按下标赋值的拷贝版:返回新数组,原数组不动。MDN 标注为 Baseline Widely available(各主流浏览器约自 2023-07 起具备);本机示例已在 Node.js v20.19.2 跑通。

核心概念

以 MDN 为准,先记住四组对照:

会改原数组 返回新数组(原数组不变)
arr.sort(compareFn?) arr.toSorted(compareFn?)
arr.reverse() arr.toReversed()
arr.splice(start, deleteCount, ...items) arr.toSpliced(start, skipCount, ...items)
arr[i] = value arr.with(index, value)

几点细节值得单独拎出来:

  • toSorted(compareFn?):行为对齐 sort 的比较函数;省略 compareFn 时按字符串 Unicode 顺序排([10, 2, 1].toSorted() 会得到 [1, 10, 2],不是数字升序)。数字请显式传 (a, b) => a - b。
  • toReversed():元素顺序颠倒的新数组;稀疏数组的空位在结果里会变成 undefined(结果是稠密数组)。
  • toSpliced(start, skipCount, ...items):从 start 删掉 skipCount 个,再插入新元素,返回改完后的完整新数组。注意:它不像 splice 那样返回「被删掉的那些元素」。
  • with(index, value):在指定下标换成新值,返回新数组。支持负下标(从末尾数);下标越界会抛 RangeError,不会默默扩容。
  • 这四个方法都是 generic:只要求 this 有 length 和整数键,也能用在类数组上;对稀疏数组,返回值一般是稠密的(空位变 undefined)。

以前常见的「先 slice() 再 sort()」能达到类似效果,但语义散、容易漏拷。官方拷贝版名字一眼能看出意图,也方便链式调用(例如 arr.with(i, v).toSorted(...))。

和 React 搭配时,思路很简单:状态更新函数里只产生新引用。setList(list => list.toSorted(...))、setList(list => list.with(i, next)) 都直接可读。若你仍在用 immer,当然也可以继续;但很多「只改一项 / 排个序」的场景,原生拷贝方法已经够用,少一层依赖。

浏览器兼容上,Can I Use / MDN Baseline 都把这组 API 标成广泛可用。打包目标若是较新的 evergreen,一般不必再 polyfill。若团队还有必须支持的旧 WebView,先查一下目标引擎的版本,再决定是否降级到 slice + sort。

示例代码

下面片段已在本机 Node 跑过断言。

排行榜:不要原地 sort,用 toSorted:

const scores = [88, 95, 72, 91];

// 降序:新数组;scores 仍是 [88, 95, 72, 91]
const ranked = scores.toSorted((a, b) => b - a);
console.log(ranked); // [95, 91, 88, 72]
console.log(scores); // [88, 95, 72, 91]

时间线倒序展示,用 toReversed:

const posts = ["周一稿", "周二稿", "周三稿"];
const newestFirst = posts.toReversed();
console.log(newestFirst); // ["周三稿", "周二稿", "周一稿"]
console.log(posts);       // 原数组不变

改某一项状态(例如列表里第 3 个分数改成 80),用 with;插入月份用 toSpliced:

const scores = [88, 95, 72, 91];
const patched = scores.with(2, 80);
console.log(patched); // [88, 95, 80, 91]
console.log(scores);  // [88, 95, 72, 91]

const months = ["Jan", "Mar", "Apr", "May"];
const withFeb = months.toSpliced(1, 0, "Feb");
console.log(withFeb); // ["Jan", "Feb", "Mar", "Apr", "May"]
console.log(months);  // 原数组不变

和 React / 不可变更新套路也能对上:setItems(items => items.with(i, next)),不必再手写 [...items.slice(0, i), next, ...items.slice(i + 1)]。

对照一下「老写法」有多容易踩坑:

const raw = [3, 1, 2];
const sortedRef = raw.sort((a, b) => a - b);
console.log(sortedRef === raw); // true:返回的就是原数组引用
console.log(raw);               // [1, 2, 3]:已经被改掉

常见误区

  1. 以为 sort / reverse 「返回了新数组」
    它们返回的是同一个数组对象,只是顺手返回了 this。写成 const next = state.list.sort(...) 等于直接改了 state 里的数组。优先 toSorted / toReversed。

  2. 省略比较函数,却期望数字升序
    默认按字符串比较。[10, 2, 1].toSorted() → [1, 10, 2]。数字、按字段排序都要自己写 compareFn——这点和 sort 完全一样。

  3. 把 toSpliced 的返回值当成「被删除的元素」
    splice 返回删除项;toSpliced 返回改完后的整份新数组。被删内容仍留在原数组里,需要的话自己从原数组取。

  4. 用 with 做「越界追加」
    arr.with(arr.length, x) 会 RangeError。追加请用 [...arr, x] 或 arr.concat(x);替换已有下标才用 with。负下标越界同样抛错。

  5. 稀疏数组预期「空位还是空位」
    拷贝版方法通常产出稠密数组,空位变成 undefined。若业务依赖稀疏语义,先确认是否还能接受,或先规范化数组。

  6. 运行环境过旧
    浏览器侧约 2023-07 起广泛可用;Node 需 20+(或带 polyfill 的旧环境)。若必须兼容很老的基线,再考虑 slice + sort 的兼容写法,但新项目优先原生拷贝 API。

  7. 在严格相等里拿「排序后的同一引用」做缓存键
    因为 sort 返回原引用,有人会误以为「排完序对象变了」。其实引用没变,依赖引用相等的 memo / useMemo 依赖数组可能不会触发重算。拷贝版每次都是新数组,语义更清晰:要刷新就换引用,不想刷新就别调用。

小结

需要排序、倒序、按下标替换、按位置增删,又不想污染原数组时,用 toSorted / toReversed / with / toSpliced。它们是 sort / reverse / 下标赋值 / splice 的官方拷贝版:返回新数组,原数组保持不变,更适合 React 状态更新和共享数据。记得数字排序要传比较函数;with 越界会抛错;toSpliced 返回的是整份新数组而不是删除项。细节以 MDN:toSorted 及同组页面为准。

参考资料

  1. Array.prototype.toSorted() — MDN
  2. Array.prototype.toReversed() — MDN
  3. Array.prototype.toSpliced() — MDN
  4. Array.prototype.with() — MDN
  5. Array — MDN(Change Array by copy 说明)
✳

谢谢你读到这里。