在一个对象数组中进行分组的最有效方法

915

什么是在数组中对对象进行groupby的最有效方法?

例如,给定以下对象数组:

[ 
    { Phase: "Phase 1", Step: "Step 1", Task: "Task 1", Value: "5" },
    { Phase: "Phase 1", Step: "Step 1", Task: "Task 2", Value: "10" },
    { Phase: "Phase 1", Step: "Step 2", Task: "Task 1", Value: "15" },
    { Phase: "Phase 1", Step: "Step 2", Task: "Task 2", Value: "20" },
    { Phase: "Phase 2", Step: "Step 1", Task: "Task 1", Value: "25" },
    { Phase: "Phase 2", Step: "Step 1", Task: "Task 2", Value: "30" },
    { Phase: "Phase 2", Step: "Step 2", Task: "Task 1", Value: "35" },
    { Phase: "Phase 2", Step: "Step 2", Task: "Task 2", Value: "40" }
]

我正在使用表格展示这些信息。我想按不同的方法进行分组,但是我想对值进行求和。

我正在使用Underscore.js的groupby函数,这很有帮助,但并不能完全满足我的需求,因为我不想让它们“分开”,而是更像SQL中的group by 方法将它们“合并”起来。

我想要的是能够对特定值进行汇总(如果被请求的话)。

所以如果我按Phase 进行分组,我希望收到:

[
    { Phase: "Phase 1", Value: 50 },
    { Phase: "Phase 2", Value: 130 }
]

如果我将 Phase/Step 进行分组,我会收到:

[
    { Phase: "Phase 1", Step: "Step 1", Value: 15 },
    { Phase: "Phase 1", Step: "Step 2", Value: 35 },
    { Phase: "Phase 2", Step: "Step 1", Value: 55 },
    { Phase: "Phase 2", Step: "Step 2", Value: 75 }
]

是否有适用于此的有用脚本,或者我应该坚持使用Underscore.js,然后循环遍历结果对象自己进行总计?


虽然 _.groupBy 本身不能完成工作,但它可以与其他 Underscore 函数结合使用来完成所需的操作,无需手动循环。请参考此回答: https://dev59.com/uGYq5IYBdhLWcg3wfgnd#66112210。 - Julian
更易读的答案版本:function groupBy(data, key){ return data.reduce( (acc, cur) => { acc[cur[key]] = acc[cur[key]] || []; // 如果键是新的,则将其值初始化为数组,否则保留其自己的数组值 acc[cur[key]].push(cur); return acc; } , []) } - aderchox
62个回答

1292

如果你想避免使用外部库,你可以简洁地实现一个纯净版的groupBy(),代码如下:

var groupBy = function(xs, key) {
  return xs.reduce(function(rv, x) {
    (rv[x[key]] = rv[x[key]] || []).push(x);
    return rv;
  }, {});
};

console.log(groupBy(['one', 'two', 'three'], 'length'));

// => {"3": ["one", "two"], "5": ["three"]}


28
我会修改代码如下:return xs.reduce(function(rv, x) { var v = typeof key === 'function' ? key(x) : x[key]; (rv[v] = rv[v] || []).push(x); return rv; }, {});允许回调函数返回排序条件。 - y_nk
155
下面是一个输出数组而不是对象的函数:groupByArray(xs, key) { return xs.reduce(function (rv, x) { let v = key instanceof Function ? key(x) : x[key]; let el = rv.find((r) => r && r.key === v); if (el) { el.values.push(x); } else { rv.push({ key: v, values: [x] }); } return rv; }, []); } - tomitrescak
49
太好了,正是我需要的。如果有其他人需要,这是TypeScript签名:var groupBy = function<TItem>(xs: TItem[], key: string) : {[key: string]: TItem[]} { ... - Michael Sandino
57
如果有人感兴趣,我制作了一个更易读且注释详细的此函数版本,并将其放在了 gist 上:https://gist.github.com/robmathers/1830ce09695f759bf2c4df15c29dd22d 我发现它对于理解这里实际发生的情况很有帮助。 - robmathers
92
我们不能使用合理的变量名吗? - HJo
显示剩余24条评论

412

使用 ES6 Map 对象:

/**
 * @description
 * Takes an Array<V>, and a grouping function,
 * and returns a Map of the array grouped by the grouping function.
 *
 * @param list An array of type V.
 * @param keyGetter A Function that takes the the Array type V as an input, and returns a value of type K.
 *                  K is generally intended to be a property key of V.
 *
 * @returns Map of the array grouped by the grouping function.
 */
//export function groupBy<K, V>(list: Array<V>, keyGetter: (input: V) => K): Map<K, Array<V>> {
//    const map = new Map<K, Array<V>>();
function groupBy(list, keyGetter) {
    const map = new Map();
    list.forEach((item) => {
         const key = keyGetter(item);
         const collection = map.get(key);
         if (!collection) {
             map.set(key, [item]);
         } else {
             collection.push(item);
         }
    });
    return map;
}


// example usage

const pets = [
    {type:"Dog", name:"Spot"},
    {type:"Cat", name:"Tiger"},
    {type:"Dog", name:"Rover"}, 
    {type:"Cat", name:"Leo"}
];
    
const grouped = groupBy(pets, pet => pet.type);
    
console.log(grouped.get("Dog")); // -> [{type:"Dog", name:"Spot"}, {type:"Dog", name:"Rover"}]
console.log(grouped.get("Cat")); // -> [{type:"Cat", name:"Tiger"}, {type:"Cat", name:"Leo"}]

const odd = Symbol();
const even = Symbol();
const numbers = [1,2,3,4,5,6,7];

const oddEven = groupBy(numbers, x => (x % 2 === 1 ? odd : even));
    
console.log(oddEven.get(odd)); // -> [1,3,5,7]
console.log(oddEven.get(even)); // -> [2,4,6]

关于Map: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map


8
你可以尝试使用 console.log(Array.from(grouped)); - mortb
1
要查看组中元素的数量:Array.from(groupBy(jsonObj, item => i.type)).map(i => ( {[i[0]]: i[1].length} )) - Ahmet Şimşek
JSON.stringify(map) 返回一个空数组。因此,如果需要字符串化,请使用对象 {} 而不是 Map。 - Omkar76
@Omkar76:你也可以使用方法 Object.fromEntries(...) 并编写:JSON.stringify(Object.fromEntries(grouped)); 如果你想从 Map 获取 JSON。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries - mortb
1
@Ansharja 是的,只要键获取函数返回一个字符串,你可以使用 JavaScript 对象代替 Map。阅读你的代码后,我想指出一些边缘情况,当仅调用字符串构造函数 -- String(keygetter) -- 来包装 keygetter 函数的输出时,可能会导致意外后果,例如在使用 Date 作为键时。 - mortb
显示剩余4条评论

167

使用ES6:

const groupBy = (items, key) => items.reduce(
  (result, item) => ({
    ...result,
    [item[key]]: [
      ...(result[item[key]] || []),
      item,
    ],
  }), 
  {},
);

6
需要一点时间来适应,但大多数C++模板也是如此。 - Levi Haskell
11
我努力思考了许久,但仍然无法理解从“...result”开始它是如何运作的。现在我因此无法入眠。 - user3307073
18
优雅,但在较大的数组上速度非常缓慢! - infinity1975
2
@user3307073,我认为乍一看似乎...result是起始值,这就是为什么很令人困惑(如果我们还没有开始构建“result”,那么...result是什么?)。 但是起始值是.reduce()的第二个参数,而它在底部:{}。 因此,您始终从JS对象开始。 相反,...result位于传递给第一个参数的{}中,因此它的意思是“从您已经拥有的所有字段开始(在添加新的“item [key]”之前)”。 - Arthur Tacca
1
@ArthurTacca 你是正确的,result 是累加器,意味着它是每个项目更新的“工作值”。它最初为空对象,每个项目都添加到分组字段值命名的属性所分配的数组中。 - Daniel
显示剩余2条评论

126
你可以使用array.reduce()来构建一个ES6的Map
const groupedMap = initialArray.reduce(
    (entryMap, e) => entryMap.set(e.id, [...entryMap.get(e.id)||[], e]),
    new Map()
);

这种方法相比其他解决方案有几个优点:
  • 它不需要任何库(不像 _.groupBy()
  • 你得到的是一个 JavaScript 的 Map 而不是一个对象(例如 _.groupBy() 返回的对象)。这有很多好处,包括:
    • 它记住了项目被添加的顺序,
    • 键可以是任何类型,而不仅仅是字符串。
  • Map 比数组数组更有用。但是,如果你确实想要一个数组数组,你可以调用 Array.from(groupedMap.entries())(得到一个 [键, 组数组] 对的数组)或者 Array.from(groupedMap.values())(得到一个简单的数组数组)。
  • 它非常灵活;通常,你计划在这个映射之后要做的任何事情都可以直接作为减少的一部分来完成。
作为最后一点的例子,想象一下我有一个对象数组,我想通过id进行(浅层)合并,就像这样:
const objsToMerge = [{id: 1, name: "Steve"}, {id: 2, name: "Alice"}, {id: 1, age: 20}];
// The following variable should be created automatically
const mergedArray = [{id: 1, name: "Steve", age: 20}, {id: 2, name: "Alice"}]

为了做到这一点,通常我会先按id进行分组,然后合并每个结果数组。但是,你可以直接在reduce()中进行合并:
const mergedArray = Array.from(
    objsToMerge.reduce(
        (entryMap, e) => entryMap.set(e.id, {...entryMap.get(e.id)||{}, ...e}),
        new Map()
    ).values()
);

后续编辑:

对于大多数情况来说,上述方法可能已经足够高效了。但是原始问题是“最高效的”,正如一些人指出的那样,上述解决方案并不是最高效的。问题主要在于为每个条目实例化一个新数组。我原本以为这会被JavaScript解释器优化掉,但似乎并非如此。

有人建议通过编辑来修复这个问题,但看起来更加复杂。即使原始代码片段已经稍微影响了可读性。如果你真的想这样做,请使用for循环!这并不是什么大罪!虽然需要多写一两行代码,但比起函数式技术来说,它更加“简单”:

const groupedMap = new Map();
for (const e of initialArray) {
    let thisList = groupedMap.get(e.type);
    if (thisList === undefined) {
        thisList = [];
        groupedMap.set(e.type, thisList);
    }
    thisList.push(e);
}

[编辑:更新为更高效的实现,避免对已存在的键同时执行.has().get()操作。]

6
我不知道为什么这个没有更多的投票。它简明扼要,易读(至少对我来说),看起来很高效。但是在IE11上无法运行(参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#Browser_compatibility),但调整不太困难(大约是`a.reduce(function(em, e){em.set(e.id, (em.get(e.id)||[]).concat([e]));return em;}, new Map())`)。 - unbob
3
因为这实际上是一种低效的解决方案,因为它在每次reduce回调调用时都会实例化一个新数组。 - Artem Balianytsia

109
GroupBy一行代码,一个ES2021解决方案
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});

TypeScript
const groupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
  array.reduce((acc, value, index, array) => {
    (acc[predicate(value, index, array)] ||= []).push(value);
    return acc;
  }, {} as { [key: string]: T[] });

例子

const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
// f -> should must return string/number because it will be use as key in object

// for demo

groupBy([1, 2, 3, 4, 5, 6, 7, 8, 9], v => (v % 2 ? "odd" : "even"));
// { odd: [1, 3, 5, 7, 9], even: [2, 4, 6, 8] };

const colors = [
  "Apricot",
  "Brown",
  "Burgundy",
  "Cerulean",
  "Peach",
  "Pear",
  "Red",
];

groupBy(colors, v => v[0]); // group by colors name first letter
// {
//   A: ["Apricot"],
//   B: ["Brown", "Burgundy"],
//   C: ["Cerulean"],
//   P: ["Peach", "Pear"],
//   R: ["Red"],
// };

groupBy(colors, v => v.length); // group by length of color names
// {
//   3: ["Red"],
//   4: ["Pear"],
//   5: ["Brown", "Peach"],
//   7: ["Apricot"],
//   8: ["Burgundy", "Cerulean"],
// }

const data = [
  { comment: "abc", forItem: 1, inModule: 1 },
  { comment: "pqr", forItem: 1, inModule: 1 },
  { comment: "klm", forItem: 1, inModule: 2 },
  { comment: "xyz", forItem: 1, inModule: 2 },
];

groupBy(data, v => v.inModule); // group by module
// {
//   1: [
//     { comment: "abc", forItem: 1, inModule: 1 },
//     { comment: "pqr", forItem: 1, inModule: 1 },
//   ],
//   2: [
//     { comment: "klm", forItem: 1, inModule: 2 },
//     { comment: "xyz", forItem: 1, inModule: 2 },
//   ],
// }

groupBy(data, x => x.forItem + "-" + x.inModule); // group by module with item
// {
//   "1-1": [
//     { comment: "abc", forItem: 1, inModule: 1 },
//     { comment: "pqr", forItem: 1, inModule: 1 },
//   ],
//   "1-2": [
//     { comment: "klm", forItem: 1, inModule: 2 },
//     { comment: "xyz", forItem: 1, inModule: 2 },
//   ],
// }

groupByToMap
const groupByToMap = (x, f) =>
  x.reduce((a, b, i, x) => {
    const k = f(b, i, x);
    a.get(k)?.push(b) ?? a.set(k, [b]);
    return a;
  }, new Map());

TypeScript
const groupByToMap = <T, Q>(array: T[], predicate: (value: T, index: number, array: T[]) => Q) =>
  array.reduce((map, value, index, array) => {
    const key = predicate(value, index, array);
    map.get(key)?.push(value) ?? map.set(key, [value]);
    return map;
  }, new Map<Q, T[]>());

1
我的 Babel 拒绝了 ||= 这个符号? - Grant
1
那是最近才标准化的。https://blog.saeloun.com/2021/06/17/es2021-logical-assignment-operator-and-or-nullish.html - loop
2
我喜欢那些简洁的、需要更多时间来理解的神奇一行代码!这绝对是最(主观上)优雅的解决方案。 - Fung
1
非常优雅,特别是能够以这种方式调整谓词。华丽。 - Micha Schopman
1
如果您想使用类似数组的对象,可以使用Array.from将其转换为数组或者参考https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce#calling_reduce_on_non-array_objects。@vitaly-t - nkitku
显示剩余3条评论

80

我建议你查看lodash groupBy,它似乎正好符合你的需求。它也非常轻量级和简单。

示例: https://jsfiddle.net/r7szvt5k/

如果你的数组名称是arr,使用lodash的groupBy方法只需要这样做:

import groupBy from 'lodash/groupBy';
// if you still use require:
// const groupBy = require('lodash/groupBy');

const a = groupBy(arr, function(n) {
  return n.Phase;
});
// a is your array grouped by Phase attribute

4
这个答案有问题吗?lodash的_.groupBy结果有多种方式不符合提问者所要求的结果格式。(1) 结果不是一个数组。(2) "value" 成为了 lodash 对象结果中的"key"。 - mg1075
1
为了简化操作,你可以直接将属性传递给groupBy函数:const a = groupBy(arr, 'Phase') - Ollie

63

虽然linq解决方案很有趣,但它相当沉重。我的方法略有不同:

var DataGrouper = (function() {
    var has = function(obj, target) {
        return _.any(obj, function(value) {
            return _.isEqual(value, target);
        });
    };

    var keys = function(data, names) {
        return _.reduce(data, function(memo, item) {
            var key = _.pick(item, names);
            if (!has(memo, key)) {
                memo.push(key);
            }
            return memo;
        }, []);
    };

    var group = function(data, names) {
        var stems = keys(data, names);
        return _.map(stems, function(stem) {
            return {
                key: stem,
                vals:_.map(_.where(data, stem), function(item) {
                    return _.omit(item, names);
                })
            };
        });
    };

    group.register = function(name, converter) {
        return group[name] = function(data, names) {
            return _.map(group(data, names), converter);
        };
    };

    return group;
}());

DataGrouper.register("sum", function(item) {
    return _.extend({}, item.key, {Value: _.reduce(item.vals, function(memo, node) {
        return memo + Number(node.Value);
    }, 0)});
});

你可以在 JSBin 上看到它的实际效果:http://jsbin.com/usepej/1/edit

我没有在 Underscore 中找到任何与 has 相同的功能,尽管我可能漏掉了。它类似于 _.contains,但是使用 _.isEqual 而不是 === 进行比较。除此之外,其余部分都是特定于问题的,尽管有一些通用性的尝试。

现在 DataGrouper.sum(data, ["Phase"]) 返回

[
    {Phase: "Phase 1", Value: 50},
    {Phase: "Phase 2", Value: 130}
]

DataGrouper.sum(data, ["Phase", "Step"]) 返回

[
    {Phase: "Phase 1", Step: "Step 1", Value: 15},
    {Phase: "Phase 1", Step: "Step 2", Value: 35},
    {Phase: "Phase 2", Step: "Step 1", Value: 55},
    {Phase: "Phase 2", Step: "Step 2", Value: 75}
]

但是 sum 只是这里的一个潜在函数。你可以根据需要注册其他函数:

DataGrouper.register("max", function(item) {
    return _.extend({}, item.key, {Max: _.reduce(item.vals, function(memo, node) {
        return Math.max(memo, Number(node.Value));
    }, Number.NEGATIVE_INFINITY)});
});

现在 DataGrouper.max(data, ["Phase", "Step"]) 将返回

[
    {Phase: "Phase 1", Step: "Step 1", Max: 10},
    {Phase: "Phase 1", Step: "Step 2", Max: 20},
    {Phase: "Phase 2", Step: "Step 1", Max: 30},
    {Phase: "Phase 2", Step: "Step 2", Max: 40}
]

或者,如果您已注册了这个:

DataGrouper.register("tasks", function(item) {
    return _.extend({}, item.key, {Tasks: _.map(item.vals, function(item) {
      return item.Task + " (" + item.Value + ")";
    }).join(", ")});
});

那么调用DataGrouper.tasks(data, ["Phase", "Step"])将会得到

[
    {Phase: "Phase 1", Step: "Step 1", Tasks: "Task 1 (5), Task 2 (10)"},
    {Phase: "Phase 1", Step: "Step 2", Tasks: "Task 1 (15), Task 2 (20)"},
    {Phase: "Phase 2", Step: "Step 1", Tasks: "Task 1 (25), Task 2 (30)"},
    {Phase: "Phase 2", Step: "Step 2", Tasks: "Task 1 (35), Task 2 (40)"}
]

DataGrouper 本身是一个函数。您可以使用您的数据和要分组的属性列表来调用它。它返回一个数组,其元素是带有两个属性的对象:key 是分组属性的集合,vals 是包含未在键中的其余属性的对象数组。例如,DataGrouper(data, ["Phase", "Step"]) 将产生:

[
    {
        "key": {Phase: "Phase 1", Step: "Step 1"},
        "vals": [
            {Task: "Task 1", Value: "5"},
            {Task: "Task 2", Value: "10"}
        ]
    },
    {
        "key": {Phase: "Phase 1", Step: "Step 2"},
        "vals": [
            {Task: "Task 1", Value: "15"}, 
            {Task: "Task 2", Value: "20"}
        ]
    },
    {
        "key": {Phase: "Phase 2", Step: "Step 1"},
        "vals": [
            {Task: "Task 1", Value: "25"},
            {Task: "Task 2", Value: "30"}
        ]
    },
    {
        "key": {Phase: "Phase 2", Step: "Step 2"},
        "vals": [
            {Task: "Task 1", Value: "35"}, 
            {Task: "Task 2", Value: "40"}
        ]
    }
]

DataGrouper.register接受一个函数,并创建一个新的函数,该函数接受初始数据和要分组的属性。然后,这个新函数按照上面的输出格式对它们中的每一个运行你的函数,返回一个新的数组。生成的函数根据你提供的名称存储为DataGrouper的属性,并且如果只想要一个本地引用,也会返回该函数。

好吧,这是很多解释。不过代码相当简单,我希望如此!


你好,我看到你可以通过一个值进行分组和求和,但如果我想根据value1、value2和value3进行求和,你有解决方案吗? - SAMUEL OSPINA
@SAMUELOSPINA,你找到解决这个问题的方法了吗? - howMuchCheeseIsTooMuchCheese

52

这可能更容易使用linq.js完成,它旨在成为JavaScript中的真正的LINQ实现(演示):

var linq = Enumerable.From(data);
var result =
    linq.GroupBy(function(x){ return x.Phase; })
        .Select(function(x){
          return {
            Phase: x.Key(),
            Value: x.Sum(function(y){ return y.Value|0; })
          };
        }).ToArray();

结果:

[
    { Phase: "Phase 1", Value: 50 },
    { Phase: "Phase 2", Value: 130 }
]

或者更简单地使用基于字符串的选择器 (演示):

linq.GroupBy("$.Phase", "",
    "k,e => { Phase:k, Value:e.Sum('$.Value|0') }").ToArray();

在这里分组时,我们可以使用多个属性吗:GroupBy(function(x){ return x.Phase; }) - Amit
linq.js 的性能如何? - Rajon Tanducar

31

MDN在Array.reduce()文档中有此示例

// Grouping objects by a property
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce#Grouping_objects_by_a_property#Grouping_objects_by_a_property

var people = [
  { name: 'Alice', age: 21 },
  { name: 'Max', age: 20 },
  { name: 'Jane', age: 20 }
];

function groupBy(objectArray, property) {
  return objectArray.reduce(function (acc, obj) {
    var key = obj[property];
    if (!acc[key]) {
      acc[key] = [];
    }
    acc[key].push(obj);
    return acc;
  }, {});
}

var groupedPeople = groupBy(people, 'age');
// groupedPeople is:
// { 
//   20: [
//     { name: 'Max', age: 20 }, 
//     { name: 'Jane', age: 20 }
//   ], 
//   21: [{ name: 'Alice', age: 21 }] 
// }

我显然缺少了什么。为什么我们不能用MDN的这个解决方案生成一个数组的数组呢?如果你尝试使用[]来初始化reducer,你会得到一个空数组作为结果。 - Stamatis Deliyannis

25

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接