12k
All articles

在 JavaScript 中使用 Object.groupBy 对数组分组

JavaScript 的 Object.groupBy 按键分组数组,对比 reduce 和 Map.groupBy,并说明字符串转换与无原型结果。

OpenReplay Team
OpenReplay Team
在 JavaScript 中使用 Object.groupBy 对数组分组

Object.groupBy(items, callback) 只需一次调用即可完成数组分组:它对每个元素执行一次回调,将返回值作为分组名称,并返回一个对象,其中每个名称下都存放着一个包含匹配元素的数组。

不少代码库至今仍带着手写的 reduce 来做这件事,同样的几行累加器样板代码在文件之间反复复制,或者为了一次 groupBy 调用而保留着 Lodash 依赖。这两种做法都没有错,也依然可用;但既然原生方法已经可用,它们就不再是必需的了。

本文将在一个具体问题上展示原生调用(按状态对订单分组),把它与被替代的 reduce 并列对比,说明何时应改用 Map.groupBy,并深入剖析在生产环境中最容易让人踩坑的两个行为:键被悄无声息地转换为字符串,以及返回的结果对象没有 hasOwnProperty

核心要点

  • Object.groupBy 以两个参数 (element, index) 调用回调,并将返回值用作分组键。
  • Object.groupBy 替换 reduce 累加器是可读性上的改进,而非性能上的改进;没有理由期望它运行得更快。
  • 当分组键不是字符串时(对象、Date,或者需要保持为数字的数值),应使用 Map.groupBy
  • 使用 Object.groupBy 按布尔值或数字分组会产生字符串键 "true""40",因为每个键都会被强制转换为属性键。
  • Object.groupBy 返回的对象具有 null 原型,因此 result.hasOwnProperty(...) 会抛出 TypeError;请改用 Object.hasOwn,或用展开语法复制该结果。

三行代码按状态对订单分组

给定一个订单对象数组,Object.groupBy 只需一个表达式即可生成以状态为键的对象,无需累加器,也无需存在性检查。

const orders = [
  { id: 1, status: "shipped",  total: 40 },
  { id: 2, status: "pending",  total: 15 },
  { id: 3, status: "shipped",  total: 60 },
  { id: 4, status: "refunded", total: 22 },
];

const byStatus = Object.groupBy(orders, (order) => order.status);

console.log(Object.keys(byStatus));   // ["shipped", "pending", "refunded"]
console.log(byStatus.shipped.length); // 2

MDN 上的 Object.groupBy() 参考文档描述了它的约定:第一个参数可以是任何可迭代对象,不限于数组,结果中每个不同的键对应一个属性。分组按其第一个成员被遇到的顺序出现。每个分组内的元素是原始对象本身,而非副本,因此修改 byStatus.shipped[0] 也会修改 orders[0]

Object.groupBy 的回调是如何工作的?

回调接收两个参数:当前元素及其索引,其返回值即成为该元素的分组键。ECMA-262 中 Object.groupBy 的定义明确规定了这两个参数;不像 mapfilter 那样存在第三个”整个数组”参数。

由于键是计算得出的,回调并不局限于读取某个字段。任何能产生字符串的表达式都可以,包括比较运算或基于索引划分的区间:

const bySize = Object.groupBy(orders, (order) => (order.total >= 50 ? "large" : "small"));
// { small: [order 1, order 2, order 4], large: [order 3] }

const byHalf = Object.groupBy(orders, (_, index) => (index < 2 ? "first" : "second"));
// { first: [order 1, order 2], second: [order 3, order 4] }

每个元素恰好落入一个分组。如果两个元素产生相同的键,它们会按插入顺序共享同一个数组。

JavaScript 分组:reduce 与 Object.groupBy 对比

Object.groupBy 替换 reduce 累加器,可以省去种子对象、逐元素的存在性检查、数组分配、push 操作以及返回语句;剩下的只有决定元素归属于哪个分组的那一行代码。

下面是大多数代码库中已有的版本,并使用逻辑空赋值写得尽可能紧凑:

const byStatusReduce = orders.reduce((acc, order) => {
  acc[order.status] ??= [];
  acc[order.status].push(order);
  return acc;
}, {});

而使用原生方法得到相同的结果:

const byStatus = Object.groupBy(orders, (order) => order.status);

两者产生等价的分组结果。差别在于读者需要在脑中承载多少信息。在 reduce 形式中,分组意图分散在累加器种子、条件分配、变更操作和返回值之中,这四处中的任何一处都可能出现细微错误。在原生形式中,唯一需要审查的只剩下键函数。

Object.groupBy 带来的是可读性上的改进,而非性能上的改进。两种方式都只遍历输入一次,并为每个分组分配一个数组,没有理由期望原生调用会比写得好的 reduce 更快。选择它是因为它消除了样板代码,而不是因为某个基准测试结果。

什么时候应该改用 Map.groupBy?

当分组键不是字符串时,请使用 Map.groupBy:对象、Date,或者需要保持为数字的数值。Map.groupBy() 接受同样的双参数回调,唯一的区别在于返回类型:一个 Map,其键正是回调返回的原始值。

const byTotal = Map.groupBy(orders, (order) => order.total);

console.log([...byTotal.keys()]);           // [40, 15, 60, 22]
console.log(typeof [...byTotal.keys()][0]); // "number"
console.log(byTotal.get(40).length);        // 1

键以数字形式返回,保持插入顺序,并通过 .get() 读取。MDN 在 Map.groupBy 页面上的示例就是按对象标识分组,而这正是对象字面量完全无法处理的场景:两个内容相同但彼此独立的对象,作为 Map 键仍然是不同的键。

Object.groupByMap.groupBy
键类型强制转换为字符串或 symbol任意值,原样保留
返回类型null 原型对象Map
读取分组result.shippedresult.get(key)
键的迭代顺序插入顺序,但类整数键会按升序排列插入顺序

为什么 Object.groupBy 会把数字键变成字符串?

使用 Object.groupBy 按布尔值或数字分组会产生字符串键 "true""40",而不是原始的 true40Map.groupBy 则会将原始值原样保留为 Map 键。回调返回的任何值最终都必须成为属性键,因此任何尚未是字符串或 symbol 的值在进入时都会被转换成字符串。这是普通的对象行为,但仍然会让期待布尔键结果的人措手不及。

const byPaid = Object.groupBy(orders, (order) => order.status === "shipped");

console.log(Object.keys(byPaid));             // ["true", "false"]
console.log(typeof Object.keys(byPaid)[0]);   // "string"
console.log(byPaid[true] === byPaid["true"]); // true(查找时同样会强制转换)

const byTotalObj = Object.groupBy(orders, (order) => order.total);
console.log(Object.keys(byTotalObj));         // ["15", "22", "40", "60"]

在数值这个例子中发生了两件事。总额变成了字符串,而且它们是按升序返回的,而非插入顺序,因为类整数属性键会先于其他字符串键、按数值升序枚举。那些期待原始顺序来遍历结果的代码,会以错误的顺序渲染分组,而且不会抛出任何异常。

分组 bug 的会话回放中经常能看到完全相同的表现:分类标题显示为 true 而不是 “Shipped”,或者数据本未排序但分桶却呈现出排序状态。控制台干干净净,日志中的数据形状看起来也没问题,因为无论键是布尔值还是字符串,{ true: [...] } 的打印结果完全一致;只有把渲染出来的 UI 与代码路径并排查看,这种强制转换才会变得显而易见。

为什么在 Object.groupBy 的结果上调用 hasOwnProperty 会抛出异常?

Object.groupBy 返回的对象具有 null 原型,因此 result.hasOwnProperty("shipped") 会抛出 TypeError;请改用 Object.hasOwn(result, "shipped"),或者当下游代码期望一个普通对象时,用展开语法复制该结果。MDN 将其返回值记录为null 原型对象,这意味着通过它无法访问 Object.prototype 上的任何东西:没有 hasOwnProperty,没有 toString,也没有 valueOf

const byStatus = Object.groupBy(orders, (o) => o.status);

byStatus.hasOwnProperty("shipped");
// TypeError: byStatus.hasOwnProperty is not a function

Object.hasOwn(byStatus, "shipped"); // true
"shipped" in byStatus;              // true
Object.keys(byStatus);              // ["shipped", "pending", "refunded"]
JSON.stringify(byStatus);           // 正常工作

const plain = { ...byStatus };      // 带有 Object.prototype 的普通对象
plain.hasOwnProperty("shipped");    // true

MDN 指出 Object.hasOwnhasOwnProperty 的现代替代方案,它自 2022 年 3 月起已达到 Baseline 广泛可用状态,因此你可以直接使用。Object.keysObject.entriesin 运算符、JSON.stringify 和展开语法都能在 null 原型结果上正常工作,因为它们都不依赖原型链。只有当某个辅助函数(通常深藏在工具库或模板引擎内部)在该对象自身上调用方法时,才会出现失败。典型症状是某个分组悄无声息地始终不渲染,或者从渲染循环内部抛出 TypeError

哪些浏览器支持 Object.groupBy 和 Map.groupBy?

Object.groupByMap.groupBy 的支持情况完全一致:两者在 MDN 上都标记为 Baseline 广泛可用,自 2024 年 3 月起在各浏览器中可用,因此针对当前浏览器目标都无需 polyfill。二者之间的取舍归结于键:如果分组名称天然就是字符串(状态、分类、团队名称),Object.groupBy 会给你一个外观普通、可用点号访问的对象。如果键是对象、Date、需要参与算术运算的数字,或者你想按布尔值比较的布尔量,Map.groupBy 会原样保留它,并避开上面提到的两个陷阱。

替换掉累加器

只要分组键是字符串,且下游没有任何代码在结果上调用 hasOwnProperty,带有 ??= [] 那一行的 reduce 就可以变成一行 Object.groupBy 调用。当键是其他类型时,改用 Map.groupBy,并通过 .get() 读取分组。无论哪种方式,唯一还需要测试的代码就是决定分组归属的那部分逻辑。

常见问题

Object.groupBy 在 TypeScript 中可用吗?它的返回类型是什么?

可以。TypeScript 5.4 为 Object.groupBy 和 Map.groupBy 添加了类型声明,当 tsconfig 的 target 或 lib 包含 es2024 或 esnext 时即可使用;较旧的 lib 设置会报告 ObjectConstructor 上不存在 groupBy。Object.groupBy 的类型是 Partial Record,因此每个分组都可能为 undefined,在索引访问之前需要做检查。Map.groupBy 的类型是从键类型到元素数组的 Map。

如果 Object.groupBy 的回调返回 undefined 或 null 会发生什么?

该元素会落入以字符串 'undefined' 或 'null' 为键的分组,因为 Object.groupBy 会把每个回调结果转换为属性键。不会跳过任何元素,也不会抛出错误,因此某个缺失的字段会悄无声息地产生一个额外的分组。Map.groupBy 则会将实际的 undefined 或 null 值保留为 Map 键。若要排除这些元素,请先过滤数组,或返回一个诸如 'unknown' 的兜底键。

Object.groupBy 与 Lodash 的 groupBy 有什么区别?

Lodash 的 groupBy 返回一个继承自 Object.prototype 的普通对象,因此 hasOwnProperty 在其上可用;Object.groupBy 返回的是 null 原型对象。Lodash 接受属性名简写,例如 groupBy(orders, 'status'),并以单个参数(值)调用函数型 iteratee,而 Object.groupBy 要求传入函数,并传递元素及其索引。Lodash 还接受普通对象作为输入;Object.groupBy 接受任何可迭代对象。两者都会将键强制转换为字符串。

如何使用 Object.groupBy 按多个字段分组?

从回调中返回一个组合字符串,例如用分隔符连接 status 和尺寸区间,生成形如 'shipped:large' 的键。Object.groupBy 没有多键模式;每个元素只会得到恰好一个属性键。如果你需要将字段分开保留,可以嵌套调用:先按 status 分组,然后对每个分组的数组针对第二个字段再执行 Object.groupBy,从而得到可通过 result.shipped.large 读取的两层结构。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.