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]:已经被改掉
常见误区
-
以为
sort/reverse「返回了新数组」
它们返回的是同一个数组对象,只是顺手返回了this。写成const next = state.list.sort(...)等于直接改了 state 里的数组。优先toSorted/toReversed。 -
省略比较函数,却期望数字升序
默认按字符串比较。[10, 2, 1].toSorted()→[1, 10, 2]。数字、按字段排序都要自己写compareFn——这点和sort完全一样。 -
把
toSpliced的返回值当成「被删除的元素」
splice返回删除项;toSpliced返回改完后的整份新数组。被删内容仍留在原数组里,需要的话自己从原数组取。 -
用
with做「越界追加」
arr.with(arr.length, x)会RangeError。追加请用[...arr, x]或arr.concat(x);替换已有下标才用with。负下标越界同样抛错。 -
稀疏数组预期「空位还是空位」
拷贝版方法通常产出稠密数组,空位变成undefined。若业务依赖稀疏语义,先确认是否还能接受,或先规范化数组。 -
运行环境过旧
浏览器侧约 2023-07 起广泛可用;Node 需 20+(或带 polyfill 的旧环境)。若必须兼容很老的基线,再考虑slice+sort的兼容写法,但新项目优先原生拷贝 API。 -
在严格相等里拿「排序后的同一引用」做缓存键
因为sort返回原引用,有人会误以为「排完序对象变了」。其实引用没变,依赖引用相等的 memo /useMemo依赖数组可能不会触发重算。拷贝版每次都是新数组,语义更清晰:要刷新就换引用,不想刷新就别调用。
小结
需要排序、倒序、按下标替换、按位置增删,又不想污染原数组时,用 toSorted / toReversed / with / toSpliced。它们是 sort / reverse / 下标赋值 / splice 的官方拷贝版:返回新数组,原数组保持不变,更适合 React 状态更新和共享数据。记得数字排序要传比较函数;with 越界会抛错;toSpliced 返回的是整份新数组而不是删除项。细节以 MDN:toSorted 及同组页面为准。
参考资料
谢谢你读到这里。