替代常见样板代码模式的现代 JavaScript 数组方法
了解诸如 groupBy、toSorted、with 和 findLast 等较新的 JavaScript 数组方法如何替代繁琐的 reduce、spread 和 reverse 解决方案。
无需库即可对数据进行分组:Object.groupBy() 和 Map.groupBy()
多年来,若要根据共同属性将项目数组分类,要么需要自己编写 reduce() 累加器,要么只能为这项任务单独引入 lodash.groupBy。
现在,ECMAScript 规范通过 Object.groupBy() 和 Map.groupBy() 提供了原生的分组功能。
它解决了什么问题
当从 API 获取扁平的记录集合——无论是交易、任务还是用户信息——在显示这些数据或对其执行业务逻辑之前,通常需要先将它们分类到不同的组中。
以前:使用 reduce() 手动实现
const inventory = [
{ name: 'Apples', type: 'fruit', quantity: 10 },
{ name: 'Bananas', type: 'fruit', quantity: 0 },
{ name: 'Carrots', type: 'vegetable', quantity: 14 },
{ name: 'Broccoli', type: 'vegetable', quantity: 5 }
];
const groupedByReduce = inventory.reduce((acc, item) => {
const key = item.type;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(item);
return acc;
}, {});
现在:直接调用专为分组设计的 Object.groupBy
const inventory = [
{ name: 'Apples', type: 'fruit', quantity: 10 },
{ name: 'Bananas', type: 'fruit', quantity: 0 },
{ name: 'Carrots', type: 'vegetable', quantity: 14 },
{ name: 'Broccoli', type: 'vegetable', quantity: 5 }
];
const grouped = Object.groupBy(inventory, item => item.type);
/*
Output:
{
fruit: [
{ name: 'Apples', type: 'fruit', quantity: 10 },
{ name: 'Bananas', type: 'fruit', quantity: 0 }
],
vegetable: [
{ name: 'Carrots', type: 'vegetable', quantity: 14 },
{ name: 'Broccoli', type: 'vegetable', quantity: 5 }
]
}
*/
应改用 Map.groupBy()
当分组键不是普通的字符串或符号,而是更复杂的对象或动态引用时,Map.groupBy() 更为合适:
const vipTier = { tier: 'VIP' };
const standardTier = { tier: 'Standard' };
const customers = [
{ name: 'Alice', plan: vipTier },
{ name: 'Bob', plan: standardTier },
{ name: 'Charlie', plan: vipTier }
];
const groupedByPlan = Map.groupBy(customers, user => user.plan);
console.log(groupedByPlan.get(vipTier));
// Returns Alice and Charlie's records directly keyed by reference
值得信赖的不可变转换方法:toSorted()、toReversed()、toSpliced()
JavaScript 中一个常见的陷阱是,人们熟悉的 .sort()、.reverse() 和 .splice() 都会直接修改数组本身,而非返回新的数组。
在那些以可预测状态为基础构建的框架中——如 React、Redux、Vue 等——直接修改数组往往会导致隐蔽的错误,使得渲染结果 silently 出现不同步。
此方法解决了什么问题
为避免这些陷阱,开发者过去常常先编写防御性复制代码,比如 [...array].sort() 或 array.slice().reverse()。而新的“通过复制修改数组”系列方法则完全省去了这些冗余代码。
两种方法的比较
// --- The Old Mutating Way ---
const scores = [88, 92, 79, 95];
const sortedMutated = scores.sort((a, b) => a - b);
console.log(scores); // [79, 88, 92, 95] -> Original data was destroyed!
// --- The Modern Non-Mutating Way ---
const originalScores = [88, 92, 79, 95];
const cleanSorted = originalScores.toSorted((a, b) => a - b);
console.log(originalScores); // [88, 92, 79, 95] -> Unchanged
console.log(cleanSorted); // [79, 88, 92, 95] -> Fresh array
用 toSpliced() 替代 splice()
toSpliced() 允许你在任意位置删除、插入或替换元素,同时不会影响原始数组:
const tabs = ['Home', 'About', 'Pricing', 'Contact'];
// Remove 'Pricing' (index 2) and insert 'Services' & 'Blog'
const updatedTabs = tabs.toSpliced(2, 1, 'Services', 'Blog');
console.log(tabs); // ['Home', 'About', 'Pricing', 'Contact']
console.log(updatedTabs); // ['Home', 'About', 'Services', 'Blog', 'Contact']
不可变地更新单个元素:使用 with()
假设你想更改某个索引处的值而不改动源数组。常见的做法是将数组展开为副本,覆盖该索引的值,然后返回结果。with() 可以将所有这些操作整合为一个简洁的表达式。
此方案解决的問題
状态更新通常需要按位置替换单个元素,同时保持周围数组的不可变性。
兩種方法的比較
const months = ['Jan', 'Mar', 'Mar', 'Apr'];
// Old way: Spread and mutate copy
const fixedMonthsOld = [...months];
fixedMonthsOld[1] = 'Feb';
// Modern way: array.with(index, value)
const fixedMonthsNew = months.with(1, 'Feb');
console.log(months); // ['Jan', 'Mar', 'Mar', 'Apr']
console.log(fixedMonthsNew); // ['Jan', 'Feb', 'Mar', 'Apr']
with()也支持負數索引,因此从末尾开始计数替换元素同样简单:
const tags = ['v1.0.0', 'v1.1.0', 'v1.2.0-beta'];
const stableTags = tags.with(-1, 'v1.2.0');
console.log(stableTags); // ['v1.0.0', 'v1.1.0', 'v1.2.0']
高效反向搜索:findLast()和findLastIndex()
使用.find()从开头向前扫描来定位元素是可行的,但它总是从索引0开始。而當實際需要找到最後一次匹配時,常用的解決方法是先反轉数组——這種方法會浪費時間,還有可能意外修改不應該改動的數據。
此方案解决的問題
诸如日志条目、撤销历史记录、动态信息流以及交易记录之类的数据都是按时间顺序排列的,因此人们通常希望获取符合特定条件的最新条目。
两种方法的比较
const logs = [
{ id: 1, action: 'LOGIN', timestamp: 1000 },
{ id: 2, action: 'UPLOAD', timestamp: 1010 },
{ id: 3, action: 'ERROR', timestamp: 1020 },
{ id: 4, action: 'UPLOAD', timestamp: 1030 },
{ id: 5, action: 'LOGOUT', timestamp: 1040 }
];
// Old way: Reversing creates overhead and array allocation
const lastUploadOld = [...logs].reverse().find(log => log.action === 'UPLOAD');
// Modern way: findLast iterates backwards directly
const lastUpload = logs.findLast(log => log.action === 'UPLOAD');
const lastUploadIdx = logs.findLastIndex(log => log.action === 'UPLOAD');
console.log(lastUpload); // { id: 4, action: 'UPLOAD', timestamp: 1030 }
console.log(lastUploadIdx); // 3
由于该方法从数组末尾开始扫描,并在找到匹配项时立即停止,因此其运行时间为 O(k),其中 k 是回溯到匹配项所需的步数——无需先复制或反转整个数组。
5. 同时执行映射与过滤:flatMap()
当每个源元素可能生成零个、一个或多个结果时,通常会将 .map() 与 .filter() 或 .flat() 连续使用。而 flatMap() 能将这一系列操作合并为一次遍历即可完成。
它能解决什么问题:当需要重新整理嵌套数据,或在同一步骤中同时进行过滤和转换时,它可以避免创建临时中间数组。
示例:展平嵌套的关系列表
const authors = [
{ name: 'Author A', books: ['Book 1', 'Book 2'] },
{ name: 'Author B', books: ['Book 3'] },
{ name: 'Author C', books: [] }
];
// Map + Flat produces 2 array allocations
const booksNested = authors.map(a => a.books).flat();
// flatMap runs the map and flattens depth-1 in one pass
const allBooks = authors.flatMap(a => a.books);
console.log(allBooks); // ['Book 1', 'Book 2', 'Book 3']
示例:同时进行转换和过滤
可以通过返回空数组来丢弃某个元素,而通过返回只包含一个元素的数组来保留该元素:
const rawInputs = ['42', 'invalid', '100', 'undefined', '256'];
const validNumbers = rawInputs.flatMap(input => {
const num = parseInt(input, 10);
return Number.isNaN(num) ? [] : [num];
});
console.log(validNumbers); // [42, 100, 256]
6. 更简洁的元素访问方式:at()
传统上,获取数组中的最后一个元素需要编写array[array.length - 1],这种方式既不方便又容易出错。
at()借鉴了Python中熟悉的负索引机制,让你能够从数组或字符串的末尾开始反向计数。
修改前后对比
const queue = ['Alice', 'Bob', 'Charlie', 'Dana'];
// Old way
const lastPersonOld = queue[queue.length - 1];
const secondLastOld = queue[queue.length - 2];
// Modern way
const lastPerson = queue.at(-1); // 'Dana'
const secondLast = queue.at(-2); // 'Charlie'
这样处理后的代码更简洁,能在计算表达式中减少“偏移一”的错误,同时由于无需先将数组存储在临时变量中,因此也更适合链式方法调用。
何时使用此方法
现代化改造检查清单
- 审查状态管理方式:用
.toSorted()和.toReversed()替代.sort()和.reverse(),以避免隐性修改错误。 - 减少冗余代码:使用
Object.groupBy()代替手动实现的累积对象。 - 提升查找效率:对于按时间顺序排列的数据,用
findLast()替代先反转再查找的方案。
.at(-1) 和 .with(index, value),无需手动进行长度计算或创建防御性数组副本。相关阅读
- 九种会导致 React 不必要重新渲染的常见模式 —— 阐述了九种会悄悄扩大重新渲染范围的常见 React 状态与效应模式,以及如何重构组件以限制更新范围。
- 十种会拖慢现代应用速度的 React 组件隐患 —— 介绍从语义化 HTML 的缺陷到缺少记忆化处理等十种常见的 React 组件错误,以及为确保应用在2026年仍保持快速、易用且无漏洞所需采取的修复措施。