在业务开发中,我们经常会拿到一组结构相似的对象数组,需要根据两个或更多字段把它们归拢成不同的集合。例如一条销售记录包含城市、渠道、金额等字段,运营希望先按城市划分,再在每个城市里按渠道统计。原生JavaScript并没有提供像SQL里GROUP BY那样的多列分组语法,因此必须自己用数组方法组合实现。

为什么单属性分组不够用
很多初学者第一次做分组时,会写出类似按一个字段分类的代码:用reduce把数组转成以该字段值为键的对象。这种方式在维度单一时没有问题,但一旦要同时看多个维度,单纯把后面的字段塞进同一个字符串键会让数据关系变扁平,后续想单独取某个城市下的所有渠道就很麻烦。
更重要的是,如果分组字段里可能出现特殊字符(比如城市名带点号),直接拼接字符串容易引发键名冲突或歧义。因此在多属性场景下,我们需要明确两种思路:复合键扁平分组,以及逐级嵌套分组。它们适用场景不同,下面分别说明。
方案一:使用复合键进行扁平分组
复合键的思路是,把多个属性值按顺序组合成一个唯一字符串,作为reduce累计对象的键。这样最终得到的是一个一层对象,每个键代表一种属性组合,对应的值是该组合下的对象数组。代码直观,适合导出Excel或做扁平统计。
下面的例子以城市与渠道两个字段分组。我们用JSON序列化或手动拼接来生成键,并借助Map避免重复创建空数组的判断逻辑。注意代码里所有小于号都做了转义,保证在HTML中安全展示。
const sales = [
{ city: '北京', channel: '线上', amount: 100 },
{ city: '北京', channel: '线下', amount: 200 },
{ city: '上海', channel: '线上', amount: 150 },
{ city: '北京', channel: '线上', amount: 50 }
];
function groupByMultiKey(list, keys) {
const map = new Map();
for (const item of list) {
const compositeKey = keys.map(k => item[k]).join('|');
if (!map.has(compositeKey)) {
map.set(compositeKey, []);
}
map.get(compositeKey).push(item);
}
return Object.fromEntries(map);
}
const result = groupByMultiKey(sales, ['city', 'channel']);
console.log(result);
// 输出: { '北京|线上': [...], '北京|线下': [...], '上海|线上': [...] }
这种写法的优点是逻辑简单,分组结果可直接遍历。缺点是丢失了层级语义:如果你想知道“北京”下共有哪些渠道,需要自己再解析键名。当分组字段超过三个时,键名也会变得很长。
方案二:逐级嵌套分组保留树状结构
如果前端需要按层级渲染(如树形表格),更合理的是建立嵌套对象:第一层是城市,第二层是渠道,叶子节点才是数组。我们可以通过递归或累进reduce来实现,让每一级都对应一个子对象。
下面演示一个通用的多属性嵌套分组函数,它接收字段数组,依次下沉。这样无论传两个还是三个字段都能复用,而且读取某城市数据就是result['北京'],结构清晰。
function groupByNested(list, keys) {
return list.reduce((acc, item) => {
let current = acc;
keys.forEach((key, index) => {
const val = item[key];
if (index === keys.length - 1) {
if (!current[val]) current[val] = [];
current[val].push(item);
} else {
if (!current[val]) current[val] = {};
current = current[val];
}
});
return acc;
}, {});
}
const tree = groupByNested(sales, ['city', 'channel']);
console.log(tree['北京']['线上']);
// 输出: [{ city: '北京', channel: '线上', amount: 100 }, { city: '北京', channel: '线上', amount: 50 }]
嵌套方案在可读性和扩展性上更好,尤其适合多维分析。但它的结果不是扁平映射,如果后端只接受一维分组格式,还需要额外展开。实际项目中可以根据消费端需求选择。
使用Map与对象的选择
在上面的示例中,复合键方案先用Map存中间状态,最后转成普通对象。这是因为Map的has和set语义明确,不必担心原型链污染。如果确定键都是字符串且数量不大,直接用空对象累加也可以,但要注意不要意外覆盖像toString这样的默认属性。
另外,当属性值本身是小写数字或_symbol之类非常规字符串时,用Map能避免隐式类型转换带来的键错位。因此在中大型项目里,推荐以Map做分组中间容器,输出时再视情况转换。
性能与注意事项
两种方案的时间复杂度都是O(n*m),其中n是数组长度,m是分组字段数,属于一次性线性遍历,性能足够应付绝大多数前台报表数据。若数据量达到十万级以上,可以考虑用for循环替代reduce以减少函数调用开销,但代码结构会稍显冗长。
需要提醒的是,分组前最好对参与分组的字段做空值兜底。比如某条记录缺少channel属性,直接取item['channel']会得到undefined,拼进键或嵌套层时可能产生“undefined”分组。建议在函数开头用|| '未知'之类的默认值处理,保证分组结果完整。
总结
在JavaScript里根据多个属性对数组对象分组,核心就是决定数据落地形态:扁平复合键适合简单统计与导出,嵌套结构适合层级展示。借助reduce、Map以及合理的默认值处理,就能用少量代码覆盖日常绝大部分分组需求,而不必引入额外库。
JavaScript数组分组对象属性修改时间:2026-08-01 10:39:28