首页 / 文章 / 替代常见样板代码模式的现代 JavaScript 数组方法

替代常见样板代码模式的现代 JavaScript 数组方法

了解诸如 groupBy、toSorted、with 和 findLast 等较新的 JavaScript 数组方法如何替代繁琐的 reduce、spread 和 reverse 解决方案。

1432 词

无需库即可对数据进行分组: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),无需手动进行长度计算或创建防御性数组副本。
  • 相关阅读