做后端出身的同学写SQL时,一句SELECT city, SUM(amount) FROM orders GROUP BY city就能拿到按城市汇总的销售额,非常直观。可到了前端,数据往往是一个对象数组,比如几千条订单记录要从接口拉回来做本地统计,很多人第一反应是写双重for循环,或者用filter对每个分组都遍历一遍,数据量一大页面明显卡顿。这篇文章就围绕如何在JavaScript中对数组对象实现类似SQL的GROUP BY与SUM操作展开,从基础写法一路讲到高性能和通用化的方案。

一、用reduce实现最基础的分组求和
reduce是数组聚合的核心武器,它把遍历和累加合并成一次循环,天然适合做分组统计。核心思路是:用一个对象(或者Map)当累加器,键是分组字段的值,值是聚合结果。遇到相同的键就累加,遇到新的键就初始化一条记录。
下面这个例子模拟了按城市分组、对amount字段求和的场景:
const orders = [
{ city: '北京', product: '手机', amount: 5000 },
{ city: '上海', product: '电脑', amount: 8000 },
{ city: '北京', product: '耳机', amount: 300 },
{ city: '上海', product: '手机', amount: 5200 },
{ city: '广州', product: '平板', amount: 2600 }
];
const result = orders.reduce((acc, cur) => {
if (acc[cur.city]) {
acc[cur.city].total += cur.amount;
acc[cur.city].count += 1;
} else {
acc[cur.city] = { city: cur.city, total: cur.amount, count: 1 };
}
return acc;
}, {});
// 需要数组形式时再做一次转换
const summary = Object.values(result);
console.log(summary);
// [{city:'北京', total:5300, count:2}, ...]这段代码只遍历一次数组,时间复杂度是O(n),比先filter再map的组合写法高效得多。要注意的一点是,reduce的初始值千万别省略。如果不传初始值,reduce会把数组第一个元素直接当累加器使用,而订单对象上并没有total这些属性,运行时会直接报NaN或者逻辑错乱。
另外提醒一个常见坑:如果分组字段的值可能来自用户输入,用普通对象做累加器时要小心原型链污染。比如某条记录的city恰好是__proto__或constructor这类键名,acc[cur.city]的判断结果会不符合预期。稳妥的写法是使用Object.create(null)创建一个没有原型链的纯净对象,或者干脆用Map,这也是下一节要讲的内容。
二、用Map提升性能与安全性
Map和普通对象相比有两个明显优势:第一,键的类型不受限制,数字、对象甚至null都能当键,不会像对象那样把键强制转成字符串;第二,Map的属性访问是真正按哈希表实现的,在键类型复杂或者数据量特别大时性能更稳定,也不用担心hasOwnProperty这类原型链问题。
用Map重写上面的分组逻辑:
function groupBySum(list, keyFn, sumKey) {
const map = new Map();
for (const item of list) {
const key = keyFn(item);
if (map.has(key)) {
const group = map.get(key);
group.total += item[sumKey];
group.count += 1;
} else {
map.set(key, { key, total: item[sumKey], count: 1 });
}
}
return [...map.values()];
}
const orders = [
{ city: '北京', amount: 5000 },
{ city: '上海', amount: 8000 },
{ city: '北京', amount: 300 }
];
console.log(groupBySum(orders, item => item.city, 'amount'));
// [{key:'北京', total:5300, count:2}, {key:'上海', total:8000, count:1}]这里把分组字段抽象成了keyFn函数,好处是分组的灵活性大大增强。如果业务需要按多个字段联合分组,也就是SQL里的GROUP BY city, product,只需要让keyFn返回一个组合键即可:
// 多字段联合分组,等价于 GROUP BY city, product const multiKeyResult = groupBySum( orders, item => item.city + '|' + item.product, 'amount' );
组合键用字符串拼接最简单,但要确保字段值里不会出现分隔符本身。如果数据不可控,更严谨的做法是用嵌套Map(外层键是city,内层键是product),或者用JSON.stringify([item.city, item.product])生成键。实测在十万条数据规模下,Map方案和普通对象方案的耗时差距不大,但Map在键为数字类型时能省去字符串转换的开销,且完全没有原型链隐患,属于更工程化的选择。
三、通用聚合工具函数:不止SUM一种玩法
真实的统计需求很少只有SUM,平均值、最大值、分组计数甚至多指标同时聚合都很常见。与其每种聚合写一遍循环,不如封装一个支持自定义聚合器的通用函数,一次遍历完成所有统计,这正是SQL里SUM(...), AVG(...), COUNT(...)同时出现的场景。
function groupAggregate(list, keyFn, aggregators) {
const map = new Map();
for (const item of list) {
const key = keyFn(item);
if (!map.has(key)) {
// 初始化每个聚合器的初始值
const init = {};
for (const name in aggregators) {
init[name] = aggregators[name].init();
}
map.set(key, init);
}
const group = map.get(key);
for (const name in aggregators) {
group[name] = aggregators[name].step(group[name], item);
}
}
// 最终输出阶段,比如平均值需要除以计数
return [...map.entries()].map(([key, group]) => {
const out = { key };
for (const name in aggregators) {
out[name] = aggregators[name].final
? aggregators[name].final(group[name], group)
: group[name];
}
return out;
});
}
const result = groupAggregate(orders, item => item.city, {
totalAmount: {
init: () => 0,
step: (acc, item) => acc + item.amount
},
avgAmount: {
init: () => ({ sum: 0, n: 0 }),
step: (acc, item) => ({ sum: acc.sum + item.amount, n: acc.n + 1 }),
final: (acc) => acc.n === 0 ? 0 : acc.sum / acc.n
},
maxAmount: {
init: () => -Infinity,
step: (acc, item) => Math.max(acc, item.amount)
},
orderCount: {
init: () => 0,
step: (acc) => acc + 1
}
});这个设计参考了init、step、final三段式的聚合模型:init负责产生初始值,step负责逐条累加,final负责收尾计算。它的好处是把聚合逻辑和分组逻辑彻底解耦,新增一种统计指标只需要在配置对象里加一项,分组代码一行都不用改。如果项目里已经在用lodash,也可以直接用_.groupBy配合_.sumBy实现,不过lodash的groupBy会先完整遍历一次构建二维结构,再对每个分组遍历求和,相当于两趟循环,数据量极大时性能不如上面的一趟式方案。
四、新特性Object.groupBy:原生分组时代的到来
从ES2024开始,JavaScript正式提供了原生的分组方法Object.groupBy和Map.groupBy,主流浏览器的较新版本已经支持。它的签名很简单:接收数组和一个回调函数,回调返回分组键,最终得到一个以分组键为属性的对象(或Map),值是原始元素的数组。
const orders = [
{ city: '北京', amount: 5000 },
{ city: '上海', amount: 8000 },
{ city: '北京', amount: 300 }
];
const grouped = Object.groupBy(orders, item => item.city);
// { '北京': [{...}, {...}], '上海': [{...}] }
// 配合Object.entries对每个分组求和
const summary = Object.entries(grouped).map(([city, list]) => ({
city,
total: list.reduce((s, o) => s + o.amount, 0)
}));
console.log(summary);原生API语义清晰、可读性高,配合Object.entries加reduce两步走,代码非常简洁。但要注意它和手写循环的性能差异:Object.groupBy先做一次完整分组,之后还要再遍历每个分组求和,整体是两趟遍历,而前文封装的groupAggregate是一趟完成。在几千到几万条数据的前端报表场景里,这点开销完全可以忽略,优先选择可读性更好的写法;只有处理十万条以上的大数据集时,才值得回到手写单趟循环的方案。
还有一点容易被忽视:如果目标运行环境(比如某些老旧的Node版本或嵌入式WebView)不支持Object.groupBy,可以引入core-js做polyfill,或者干脆保留自己封装的groupBy函数作为兜底。工程实践中建议把分组聚合收敛到统一的工具函数里,环境支持就委托原生API,不支持就走polyfill路径,业务代码完全不感知差异。
总结一下,小数据量求可读性,用Object.groupBy加reduce;大数据量求性能,用Map加单趟循环;需求多变求扩展性,用init、step、final三段式的通用聚合器。把SQL聚合的思维方式移植到JavaScript里,本质上就是把声明式查询翻译成一次哈希分桶加逐条累加的过程,掌握这个核心模型,以后遇到再复杂的统计需求都能从容拆解。
JavaScript数组分组reduce聚合GROUP BY修改时间:2026-09-16 03:28:40