在维护一个任务管理工具时,我发现一个反复出现的bug:用户对任务列表排序后,原有的原始顺序再也找不回来了;撤回功能也经常出岔子,因为sort()和reverse()会直接修改原数组。类似的问题在React和Vue的状态管理中也相当常见——无意中修改了state的引用,导致组件不更新或产生难以追踪的副作用。
直到去年底,浏览器慢慢实装了几个看似微小却极其趁手的数组新方法,以及Object.groupBy分组方法,我才彻底把数组操作改头换面了一番。这篇文章就用我实际改造那个任务工具的完整过程,带你看清这些新方法的用法和好处。
告别变异:一组“不毁原数组”的新方法
在ES2023中正式定稿的这些方法,名称都是在原有变异方法前面加了个to,并且全部返回新数组:
toSorted()—— 对应sort()toReversed()—— 对应reverse()toSpliced()—— 对应splice()with()—— 替换指定索引的元素并返回新数组
它们与原有方法的最大不同,就是不会动原来的数组,而是给你一个拷贝过的修改版本。这在状态不可变的前端框架里简直是久旱逢甘霖。
toSorted:排序不伤原数据
假设有一个任务列表,我们需要按优先级排序,但又要保留原始插入顺序以备回溯。用旧的sort():
const tasks = [
{ id: 1, title: '修复登录页BUG', priority: 3 },
{ id: 2, title: '更新用户手册', priority: 1 },
{ id: 3, title: '数据库优化', priority: 2 },
];
const sortedByPriority = tasks.sort((a, b) => b.priority - a.priority);
// tasks 已经被改得面目全非,原始顺序丢失
换用toSorted():
const sorted = tasks.toSorted((a, b) => b.priority - a.priority);
console.log(tasks[0].title); // 仍然是“修复登录页BUG”
console.log(sorted[0].title); // “修复登录页BUG”(优先级最高)
数组的原始引用保持不变,React 的 setState 可以放心调用,不会产生不必要的竞态。
toReversed:逆序不破坏
const reversed = tasks.toReversed();
// tasks 仍按原序
如果在需要快速翻转视图的场景(比如最新评论置顶 vs 最早评论置顶),可以用新数组切换而不污染原始数据。
toSpliced:删除替换不伤身
原splice(index, count, ...items)会直接挖去原数组并返回删除项,同时改变原数组。现在toSpliced只返回修改后的新数组,原数组纹丝不动:
const removedTask = tasks[1];
const filtered = tasks.toSpliced(1, 1); // 删除索引1的任务,返回新数组
console.log(tasks.length); // 3,原数组未变
console.log(filtered.length); // 2
with():单点更新
替换某个索引的值,相当于arr[index] = newValue,但不可变:
const updated = tasks.with(0, { ...tasks[0], priority: 5 });
console.log(tasks[0].priority); // 3 没变
console.log(updated[0].priority); // 5
这在更新状态数组中某个元素时格外好用,因为直接展开[...tasks]然后修改索引既啰嗦又容易出错。
Object.groupBy:自带的分组能力
很长一段时间,想要对数组分组,要么自己写reduce,要么引入lodash的groupBy。现在ES2024带来了原生的Object.groupBy和Map.groupBy,直接按回调返回值分组。
在我们的任务工具里,需要按状态分组显示:
const tasks = [
{ title: '写周报', status: 'done' },
{ title: '评审代码', status: 'in-progress' },
{ title: '设计新功能', status: 'todo' },
{ title: '修复UI瑕疵', status: 'done' },
];
const grouped = Object.groupBy(tasks, task => task.status);
console.log(grouped);
/*
{
done: [{ title: '写周报', status: 'done' }, { title: '修复UI瑕疵', status: 'done' }],
'in-progress': [{ title: '评审代码', status: 'in-progress' }],
todo: [{ title: '设计新功能', status: 'todo' }]
}
*/
返回的是一个没有原型的对象(null prototype),所以你不必担心constructor等属性干扰。如果键可能不是字符串,可以用Map.groupBy得到真正的Map。
综合案例:改造任务管理工具
我们把上面的方法论组装成一个简单的任务管理组件(这里用纯JS模拟状态更新,不依赖框架)。
// 初始任务
let state = [
{ id: 1, title: '写周报', status: 'todo', priority: 2 },
{ id: 2, title: '修复登录', status: 'done', priority: 3 },
{ id: 3, title: '用户手册', status: 'in-progress', priority: 1 },
{ id: 4, title: '性能优化', status: 'todo', priority: 3 },
];
// 1. 按优先级降序查看(不破坏state)
const sortedView = state.toSorted((a, b) => b.priority - a.priority);
// 2. 完成一个任务:将id=1的任务状态改为'done'
const completeId = 1;
const index = state.findIndex(t => t.id === completeId);
if (index > -1) {
state = state.with(index, { ...state[index], status: 'done' });
}
// 3. 删除id=2的任务
const deleteId = 2;
const deleteIndex = state.findIndex(t => t.id === deleteId);
if (deleteIndex > -1) {
state = state.toSpliced(deleteIndex, 1);
}
// 4. 按状态分组展示
const grouped = Object.groupBy(state, t => t.status);
console.log('当前状态:', grouped);
// { todo: [...], done: [...], 'in-progress': [...] }
整个过程没有一个变异操作,每一步都是基于当前状态生成新状态。如果需要在React中,直接setState(newState)就能触发精确的重新渲染,避免深拷贝的性能浪费。
兼容性与启用方式
toSorted()等方法在Chrome 110+、Firefox 115+、Safari 16.4+、Edge 110+ 均已可用,Node.js 20+ 也原生支持。Object.groupBy在Chrome 117+、Firefox 119+、Node.js 21+ 中默认可用。如果你需要照顾上一代浏览器,可以从core-js或polyfill.io引入polyfill,或者使用Babel插件转换。
如果你当前项目依赖lodash的_.groupBy,完全可以用原生替代,这能省掉额外的包体积。
使用中注意的细节
- 这些新方法返回的是浅拷贝,对象元素不会递归拷贝。修改内部对象仍然会影响原数组对应的元素,应结合扩展运算符或不可变库使用。
with()接受负数索引,类似at(),方便倒着定位。Object.groupBy的回调可以返回数字,但数字会被自动转为字符串键,可能造成“数字转字符串”的意外,如果介意可以用Map.groupBy。- 对于超长数组,频繁创建拷贝可能带来性能开销,但现代JS引擎对此有优化(写时拷贝等),一般场景不值得提前焦虑。
总结
从直接修改变量的“野路子”,到有节制的不可变操作,这几个新方法补上了JavaScript标准库长久以来的大缺口。它们带来的不仅是更少的bug,更是一种编程思维的校正:数据一旦创建,就不要轻易改动它,一切都通过新数据来呈现变化。
在真实的任务管理改造中,我们删掉了好几个为了拷贝数组而写的[...arr]和slice(),也不用再担心排序突然弄乱了原始列表。如果你手上刚好有个数组操作密集的模块,不妨拿这些新方法重写一遍,那种安全感会在你第一次撤销操作时充分显现。

