← 回到手记
代码

groupBy:别再手写 reduce 分组了

ES2024 的 Object.groupBy / Map.groupBy 用回调直接分组,省掉 reduce + ??= [] 样板;字符串键用 Object,对象键用 Map(Node 21+ / 2024-03 起浏览器广泛可用)。

要解决的问题

前端里「按字段分组」太常见了:订单按状态拆成待付款 / 已发货,标签页按 type 分栏,库存按库存量分成「够用」和「要补货」。很多人第一反应是手写 reduce:

const byType = items.reduce((acc, item) => {
  (acc[item.type] ??= []).push(item);
  return acc;
}, {});

能跑,但每次都要记得初始化空数组、别写错累加器、还得想清楚键是字符串还是别的东西。ES2024 起,语言自带了静态方法 Object.groupBy 和 Map.groupBy:传入可迭代对象和一个回调,直接得到分组结果。MDN 标为 Baseline Widely available(各主流浏览器约自 2024-03 起具备);Node.js 需 21+(本机示例在 Node.js v22.21.0 跑通;系统自带的 Node 20 尚无此 API)。

核心概念

两个 API 的签名几乎一样,差别在「键」和「返回值」:

Object.groupBy(items, callbackFn)  // → 无原型对象(null-prototype)
Map.groupBy(items, callbackFn)     // → Map
  • items:任意可迭代对象(数组最常见)。
  • callbackFn(element, index):对每个元素返回分组键。
  • Object.groupBy:键会被当成属性键(字符串或 symbol;其它类型会强制转成字符串)。返回的是 Object.create(null) 那种没有原型的对象,每个键对应一个数组。
  • Map.groupBy:键可以是任意值(对象、数字、Date……),按 SameValueZero / 引用身份 进 Map。适合「用对象当分组标签」的场景。

分组结果里的元素和原数组是同一份引用,不是深拷贝。改元素内部字段,两边都能看到——这点和 reduce 推进去的是同一回事。

早期提案里曾出现过 Array.prototype.group / groupToMap,后来因 Web 兼容性改成了现在的静态方法。别再搜 arr.group(...),标准形态就是 Object.groupBy / Map.groupBy。

选用口诀很简单:键能用字符串/symbol 表达 → Object.groupBy;键必须是对象或要保留非字符串身份 → Map.groupBy。

示例代码

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

按 type 分组(最常见):

const inventory = [
  { name: "asparagus", type: "vegetables", quantity: 9 },
  { name: "bananas", type: "fruit", quantity: 5 },
  { name: "goat", type: "meat", quantity: 23 },
  { name: "cherries", type: "fruit", quantity: 12 },
  { name: "fish", type: "meat", quantity: 22 },
];

const byType = Object.groupBy(inventory, ({ type }) => type);
console.log(byType.fruit);
// [
//   { name: "bananas", type: "fruit", quantity: 5 },
//   { name: "cherries", type: "fruit", quantity: 12 }
// ]
console.log(Object.getPrototypeOf(byType)); // null

按业务规则分组(库存是否够用):

const byStock = Object.groupBy(inventory, ({ quantity }) =>
  quantity < 6 ? "restock" : "sufficient"
);
console.log(byStock.restock);
// [{ name: "bananas", type: "fruit", quantity: 5 }]

数字等非字符串键会被强制转成字符串属性名:

const nums = [1, 2, 3, 4, 5];
const byParity = Object.groupBy(nums, (n) => n % 2);
console.log(byParity["0"]); // [2, 4]
console.log(byParity["1"]); // [1, 3, 5]

需要对象键时用 Map.groupBy(注意必须用同一个对象引用去 get):

const restock = { label: "restock" };
const sufficient = { label: "sufficient" };
const grouped = Map.groupBy(inventory, ({ quantity }) =>
  quantity < 6 ? restock : sufficient
);

console.log(grouped.get(restock));
// [{ name: "bananas", type: "fruit", quantity: 5 }]

const lookalike = { label: "restock" };
console.log(grouped.get(lookalike)); // undefined:长得像没用,必须是原引用

对照一眼旧写法:Object.groupBy 省掉了累加器样板,意图也更直白——「按这个回调分组」,而不是「我在 reduce 里拼对象」。

常见误区

  1. 拿返回对象调 hasOwnProperty / toString
    Object.groupBy 的结果是 null 原型对象,没有继承自 Object.prototype 的方法。groups.hasOwnProperty("fruit") 会直接抛错。查键用 Object.hasOwn(groups, "fruit"),或 'fruit' in groups。

  2. 以为键「原样保留」
    Object.groupBy 会把非字符串/非 symbol 的返回值转成字符串。n % 2 得到的是数字 0 / 1,属性名却是 "0" / "1"。需要保留数字或对象身份时,改用 Map.groupBy。

  3. Map.groupBy 用「长得一样的新对象」去取
    Map 的对象键按引用比。分组时用的那个 { restock: true },取值时还必须是同一个变量;重新 const k = { restock: true } 再 get(k) 会得到 undefined。

  4. 以为分组结果是深拷贝
    数组里的元素还是原对象。改 byType.fruit[0].quantity,原 inventory 里对应项也会变。若要独立快照,分组后再自己拷贝。

  5. 还在写 arr.group(...)
    提案早期的实例方法没有成为标准。正确写法是静态的 Object.groupBy(arr, fn) / Map.groupBy(arr, fn)。

  6. 运行环境过旧
    浏览器侧约 2024-03 起广泛可用(Chrome 117+、Firefox 119+、Safari 17.4+ 量级);Node 需 21+。还在 Node 20 LTS 上跑构建脚本的话,要么升到 22 LTS,要么继续用 reduce / 工具库,不要假设全局已有 Object.groupBy。

  7. 空分组不会出现
    回调从未返回过的键,结果里就没有对应属性/Map 条目。UI 若要固定展示「所有状态栏」(哪怕某栏是空数组),需要自己补默认键,不能指望 groupBy 预填。

小结

按字段或规则把列表拆成多组时,优先用 Object.groupBy;键是对象或必须保留非字符串身份时,用 Map.groupBy。它们替代了大半「reduce + ??= []」样板,返回值分别是 null 原型对象和 Map。记住:元素是浅引用、对象键看身份、null 原型上别调 hasOwnProperty,环境要 Node 21+ / 近一年的 evergreen 浏览器。细节以 MDN:Object.groupBy 与 Map.groupBy 为准。

参考资料

  1. Object.groupBy() — MDN
  2. Map.groupBy() — MDN
  3. Object — MDN
  4. Map — MDN
  5. tc39/proposal-array-grouping
✳

谢谢你读到这里。