在 JavaScript 中使用 Object.groupBy 对数组分组
JavaScript 的 Object.groupBy 按键分组数组,对比 reduce 和 Map.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 的定义明确规定了这两个参数;不像 map 或 filter 那样存在第三个”整个数组”参数。
由于键是计算得出的,回调并不局限于读取某个字段。任何能产生字符串的表达式都可以,包括比较运算或基于索引划分的区间:
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.groupBy | Map.groupBy | |
|---|---|---|
| 键类型 | 强制转换为字符串或 symbol | 任意值,原样保留 |
| 返回类型 | null 原型对象 | Map |
| 读取分组 | result.shipped | result.get(key) |
| 键的迭代顺序 | 插入顺序,但类整数键会按升序排列 | 插入顺序 |
为什么 Object.groupBy 会把数字键变成字符串?
使用 Object.groupBy 按布尔值或数字分组会产生字符串键 "true" 和 "40",而不是原始的 true 和 40;Map.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.hasOwn 是 hasOwnProperty 的现代替代方案,它自 2022 年 3 月起已达到 Baseline 广泛可用状态,因此你可以直接使用。Object.keys、Object.entries、in 运算符、JSON.stringify 和展开语法都能在 null 原型结果上正常工作,因为它们都不依赖原型链。只有当某个辅助函数(通常深藏在工具库或模板引擎内部)在该对象自身上调用方法时,才会出现失败。典型症状是某个分组悄无声息地始终不渲染,或者从渲染循环内部抛出 TypeError。
哪些浏览器支持 Object.groupBy 和 Map.groupBy?
Object.groupBy 和 Map.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 读取的两层结构。
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