在业务代码里给数组分组是再常见不过的操作了。有一组订单,想按状态分成“待支付”“已发货”“已完成”;有一组用户,想按年龄段归类;有一组日志,想按级别汇总。每次遇到这种需求,很多人的第一反应是拿出 reduce,写一个累加器,往对象里塞键值对。一个两个还好,但项目里到处都是相似的 reduce 模板,阅读起来得先反应一下这个回调到底在干什么,维护久了容易审美疲劳。
好在 ES2024 正式带来了 Object.groupBy 和 Map.groupBy 两个静态方法,专门用来做数组分组。用法跟 reduce 完全不同:你只需要提供一个分组键的回调函数,返回的结果自动按组归类。这篇文章以一个订单报表的案例为主线,先把传统 reduce 分组的写法亮出来,再用 groupBy 重写,最后补充一些迁移注意点和边界情况。
传统 reduce 分组:能跑但费眼
假设订单列表长这样:
const orders = [
{ id: '001', status: 'pending', amount: 99 },
{ id: '002', status: 'shipped', amount: 150 },
{ id: '003', status: 'pending', amount: 60 },
{ id: '004', status: 'completed', amount: 200 },
{ id: '005', status: 'shipped', amount: 80 },
{ id: '006', status: 'pending', amount: 120 },
];
按照 status 分组,传统做法是这样:
const groupedByStatus = orders.reduce((acc, order) => {
const key = order.status;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(order);
return acc;
}, {});
console.log(groupedByStatus);
// 输出:
// {
// pending: [ { id:'001', ... }, { id:'003', ... }, { id:'006', ... } ],
// shipped: [ { id:'002', ... }, { id:'005', ... } ],
// completed: [ { id:'004', ... } ]
// }
这段代码能正常工作,但有几个让人不舒服的点。第一,acc 的初始值是一个空对象,分组键动态生成,类型检查工具很难推断返回值中各键的含义。第二,if (!acc[key]) 这一行在实际写的时候经常被忘记,导致第一次碰到新键时因为 undefined 而报错。第三,reduce 本身表达的意图是“累加”,用它来做分组其实是把分组逻辑强塞进累加器的壳里,不够自解释。
如果分组键需要某种转换(比如按年份归类),reduce 回调还会膨胀:
const ordersByYear = orders.reduce((acc, order) => {
const year = new Date(order.createdAt).getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(order);
return acc;
}, {});
多写几个这样的分组函数之后,团队里就会出现各种封装工具函数试图简化,但最终又回到了自己造的轮子上。
Object.groupBy:一行代码完成分组
Object.groupBy(array, callback) 接受两个参数:要分组的数组和一个用来提取分组键的函数。返回值是一个原型为 null 的对象,这意味着它不会继承 Object.prototype 的任何方法(如 toString、hasOwnProperty),纯粹是一个键值映射。这其实是个优点——防止键名冲突(比如键恰好叫 toString)。
用 Object.groupBy 重写上面的分组,代码量骤减:
const groupedByStatus = Object.groupBy(orders, (order) => order.status);
console.log(groupedByStatus);
// 结果与 reduce 版本相同,但对象原型为 null
不需要手动初始化数组,不需要 if 判断键是否存在,阅读的人一眼就看出“这是按 status 分组”。如果分组键需要转换,直接在第二个回调里处理:
const ordersByYear = Object.groupBy(orders, (order) =>
new Date(order.createdAt).getFullYear()
);
Object.groupBy 返回的对象原型为 null,意味着你不能用 .hasOwnProperty 或 toString,但可以直接用 Object.keys、Object.entries、in 操作符来遍历。这对分组场景来说几乎没有任何负面影响。
Map.groupBy:当分组键不是字符串时
Object.groupBy 的所有键都会被强制转换成字符串或 Symbol。如果分组键本身是对象、数字或你希望保留原始类型,需要用到 Map.groupBy。它返回一个标准的 Map 实例,键可以是任意类型。
比如,订单对象里有一个 customer 字段,它是个对象引用,我们想按客户对象来分组(同一个客户对象的订单归到一组):
const customerA = { id: 'c1', name: '张三' };
const customerB = { id: 'c2', name: '李四' };
const ordersWithCustomer = [
{ id: '1', amount: 50, customer: customerA },
{ id: '2', amount: 80, customer: customerB },
{ id: '3', amount: 30, customer: customerA },
];
const byCustomer = Map.groupBy(ordersWithCustomer, (o) => o.customer);
console.log(byCustomer.get(customerA));
// [{ id:'1', ... }, { id:'3', ... }]
console.log(byCustomer.get(customerB));
// [{ id:'2', ... }]
Map.groupBy 在需要对分组结果做进一步操作时也更顺手,因为 Map 自带了 forEach、entries、has 等遍历和查询方法,不需要先转成数组再操作。
完整案例:订单报表生成
现在来一个更贴近实际需求的例子。系统需要生成一份按订单状态汇总的报表,每个状态下包含订单列表以及该状态的总金额。用 Object.groupBy 搭配 Array.map 和 reduce 写一个干净的数据处理管线:
const orders = [
{ id: '1', status: 'pending', amount: 99 },
{ id: '2', status: 'shipped', amount: 150 },
{ id: '3', status: 'pending', amount: 60 },
{ id: '4', status: 'completed', amount: 200 },
{ id: '5', status: 'shipped', amount: 80 },
];
// 第一步:按状态分组
const byStatus = Object.groupBy(orders, (o) => o.status);
// 第二步:为每个状态生成汇总
const report = Object.entries(byStatus).map(([status, items]) => {
const totalAmount = items.reduce((sum, item) => sum + item.amount, 0);
return {
status,
count: items.length,
totalAmount,
orders: items,
};
});
console.log(report);
// [
// { status: 'pending', count: 2, totalAmount: 159, orders: [...] },
// { status: 'shipped', count: 2, totalAmount: 230, orders: [...] },
// { status: 'completed', count: 1, totalAmount: 200, orders: [...] },
// ]
整个流程没有出现任何显式的键检查,也没有去操作一个中间累加器。先从 Object.groupBy 拿到分组对象,再用 Object.entries 展开成数组做二次计算。读起来几乎就是把自然语言的步骤翻译成了代码。
如果你更倾向于直接拿到一个 Map,以便后续再按其他维度做操作,可以换成 Map.groupBy,再把 Map 的 forEach 当作遍历手段。
与 reduce 的性能差异?
理论上 Object.groupBy 的内部实现也是遍历一次并收集结果,和手写 reduce 的效率在同一量级。因为在 V8 中是原生实现,省略了 JavaScript 层的回调调用和动态键检查,实际性能往往比手写 reduce 稍快。但在绝大多数业务场景里,这点性能差异远小于代码可读性带来的收益。
如果数组有几十万项,分组操作本身的复杂度是 O(n),原生实现可能会有优化,但瓶颈通常不在这里,而在于后续的数据处理或 UI 渲染。所以放心用,不必担心性能退化。
浏览器兼容与迁移策略
Object.groupBy 和 Map.groupBy 已经在 2024 年下半年被所有主流浏览器原生支持,包括 Chrome 117+、Edge 117+、Firefox 119+、Safari 17.4+。Node.js 从 21.0 开始也支持。如果你的运行环境是较新的浏览器或 LTS 版本的 Node,可以直接用而不需要 polyfill。
对于暂时需要支持旧环境的项目,可以引入 core-js 的 polyfill(core-js/proposals/array-grouping),也可以在构建阶段用 Babel 转译。不过这两个方法是静态方法,不是数组原型上的方法,polyfill 相当轻量。如果实在不好加 polyfill,继续用 reduce 也不会错,只是代码会多一些。
迁移的时候,没有必要把项目里所有的 reduce 分组一次性替换掉。建议在新增功能或重构某模块时顺手改掉,让改进随着正常开发节奏逐步渗透。
其他你可能不知道的细节
回调可以返回 undefined。 如果分组回调返回 undefined,这个元素会被丢弃,不会出现在分组结果中。这可以用来过滤某些不需要的值。比如,只想保留有 owner 属性的任务:
const tasks = [
{ name: 'task A', owner: 'Alice' },
{ name: 'task B' }, // 没有 owner
{ name: 'task C', owner: 'Bob' },
];
const byOwner = Object.groupBy(tasks, (t) => t.owner);
// 结果: { Alice: [...task A...], Bob: [...task C...] }
// 没有 owner 的 task B 被丢弃了
这实际上是把“过滤”和“分组”合并在了一步,但语义是隐式的。如果团队中有人不熟悉这个行为,可能会意外丢掉数据,建议在回调中显式处理返回 undefined 的场景,或者直接改用 filter + groupBy 的组合。
空数组直接返回空对象/空 Map。 不会报错:
Object.groupBy([], (x) => x.type); // 返回原型为 null 的空对象 {}
Map.groupBy([], (x) => x.type); // 返回空 Map(0)
这比 reduce 省了一个初始值的检查,直接就能安全使用。
总结
Object.groupBy 和 Map.groupBy 不是什么颠覆性的语言特性,但它们把最常见的数据转换操作之一——数组分组——从每次都要亲手写出 if-acc 的逻辑中解放了出来。代码更短,意图更清晰,减少了忘记初始化键的低级错误。订单报表的例子只是一个起点,几乎任何需要按维度聚合的业务报表、数据看板、分析模块都能用这套方法简化。
如果你还在用 reduce 写分组,下次写新功能时可以试试 groupBy。一用就回不去,很可能你回头就去重构老代码。

