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

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个回答

25

有点晚了,但或许还是有人会喜欢这个。

ES6:

const users = [{
    name: "Jim",
    color: "blue"
  },
  {
    name: "Sam",
    color: "blue"
  },
  {
    name: "Eddie",
    color: "green"
  },
  {
    name: "Robert",
    color: "green"
  },
];
const groupBy = (arr, key) => {
  const initialValue = {};
  return arr.reduce((acc, cval) => {
    const myAttribute = cval[key];
    acc[myAttribute] = [...(acc[myAttribute] || []), cval]
    return acc;
  }, initialValue);
};

const res = groupBy(users, "color");
console.log("group by:", res);


1
谢谢,你的方法有效。我对这个概念有点新,你能解释一下initialValue部分是什么意思吗?它做了什么? - Praveen Vishnu
@PraveenVishnu initialValue是reduce回调函数的一部分,我只是想明确地添加它。https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce - Kmylo darkstar
用ES6另一种编写方式是:const groupBy = (arr, key) => { const initialValue = {}; const reducer = (acc, currentObj) => { const groupByKey = currentObj[key]; const currentGroup = acc[groupByKey] || []; return { ...acc, [groupByKey]: [...currentGroup, currentObj], }; }; return arr.reduce(reducer, initialValue); }; - Kmylo darkstar

22
Array.prototype.groupBy = function(keyFunction) {
    var groups = {};
    this.forEach(function(el) {
        var key = keyFunction(el);
        if (key in groups == false) {
            groups[key] = [];
        }
        groups[key].push(el);
    });
    return Object.keys(groups).map(function(key) {
        return {
            key: key,
            values: groups[key]
        };
    });
};

20
你可以使用Alasql JavaScript库来实现。
var data = [ { Phase: "Phase 1", Step: "Step 1", Task: "Task 1", Value: "5" },
             { Phase: "Phase 1", Step: "Step 1", Task: "Task 2", Value: "10" }];

var res = alasql('SELECT Phase, Step, SUM(CAST([Value] AS INT)) AS [Value] \
                  FROM ? GROUP BY Phase, Step',[data]);

请在 jsFiddle 中尝试此示例。

顺便说一句:对于大数组(100000条及以上的记录),Alasql比Linq更快。请参见jsPref中的测试。

注释:

  • 我在方括号中放置了Value,因为VALUE是SQL中的关键字。
  • 我必须使用CAST()函数将字符串Values转换为数字类型。

20

一种新的方法是使用一个对象来进行分组,再使用两个函数来创建一个键并获取所需的分组项对象和另一个键来添加值。

const
    groupBy = (array, groups, valueKey) => {
        const
            getKey = o => groups.map(k => o[k]).join('|'),
            getObject = o => Object.fromEntries([...groups.map(k => [k, o[k]]), [valueKey, 0]]);

        groups = [].concat(groups);

        return Object.values(array.reduce((r, o) => {
            (r[getKey(o)] ??= getObject(o))[valueKey] += +o[valueKey];
            return r;
        }, {}));
    },
    data = [{ 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" }];

console.log(groupBy(data, 'Phase', 'Value'));
console.log(groupBy(data, ['Phase', 'Step'], 'Value'));
.as-console-wrapper { max-height: 100% !important; top: 0; }

旧方法:

虽然这个问题有一些答案,而且答案看起来有点复杂,但我建议使用原生的JavaScript配合嵌套(如果必要)Map来进行分组。

function groupBy(array, groups, valueKey) {
    var map = new Map;
    groups = [].concat(groups);
    return array.reduce((r, o) => {
        groups.reduce((m, k, i, { length }) => {
            var child;
            if (m.has(o[k])) return m.get(o[k]);
            if (i + 1 === length) {
                child = Object
                    .assign(...groups.map(k => ({ [k]: o[k] })), { [valueKey]: 0 });
                r.push(child);
            } else {
                child = new Map;
            }
            m.set(o[k], child);
            return child;
        }, map)[valueKey] += +o[valueKey];
        return r;
    }, [])
};

var data = [{ 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" }];

console.log(groupBy(data, 'Phase', 'Value'));
console.log(groupBy(data, ['Phase', 'Step'], 'Value'));
.as-console-wrapper { max-height: 100% !important; top: 0; }


15

检查答案——只是浅层分组。理解reduce非常好。问题还提供了额外聚合计算的问题。

这里是一个真正的按某个字段对对象数组进行分组的GROUP BY,包括1)计算的键名和2)级联分组的完整解决方案,提供所需键列表并将其唯一值转换为根键,就像SQL GROUP BY一样。

const inputArray = [ 
    { 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" }
];

var outObject = inputArray.reduce(function(a, e) {
  // GROUP BY estimated key (estKey), well, may be a just plain key
  // a -- Accumulator result object
  // e -- sequentally checked Element, the Element that is tested just at this itaration

  // new grouping name may be calculated, but must be based on real value of real field
  let estKey = (e['Phase']); 

  (a[estKey] ? a[estKey] : (a[estKey] = null || [])).push(e);
  return a;
}, {});

console.log(outObject);

玩转 estKey -- 你可以按照多个字段进行分组,添加额外的聚合、计算或其他处理。

此外,你可以递归地进行数据分组。例如,首先按Phase字段分组,然后按Step字段分组,以此类推。另外可以排除掉无用的数据。

const inputArray = [
{ 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" }
  ];

/**
 * Small helper to get SHALLOW copy of obj WITHOUT prop
 */
const rmProp = (obj, prop) => ( (({[prop]:_, ...rest})=>rest)(obj) )

/**
 * Group Array by key. Root keys of a resulting array is value
 * of specified key.
 *
 * @param      {Array}   src     The source array
 * @param      {String}  key     The by key to group by
 * @return     {Object}          Object with grouped objects as values
 */
const grpBy = (src, key) => src.reduce((a, e) => (
  (a[e[key]] = a[e[key]] || []).push(rmProp(e, key)),  a
), {});

/**
 * Collapse array of object if it consists of only object with single value.
 * Replace it by the rest value.
 */
const blowObj = obj => Array.isArray(obj) && obj.length === 1 && Object.values(obj[0]).length === 1 ? Object.values(obj[0])[0] : obj;

/**
 * Recursive grouping with list of keys. `keyList` may be an array
 * of key names or comma separated list of key names whom UNIQUE values will
 * becomes the keys of the resulting object.
 */
const grpByReal = function (src, keyList) {
  const [key, ...rest] = Array.isArray(keyList) ? keyList : String(keyList).trim().split(/\s*,\s*/);
  const res = key ? grpBy(src, key) : [...src];
  if (rest.length) {
for (const k in res) {
  res[k] = grpByReal(res[k], rest)
}
  } else {
for (const k in res) {
  res[k] = blowObj(res[k])
}
  }
  return res;
}

console.log( JSON.stringify( grpByReal(inputArray, 'Phase, Step, Task'), null, 2 ) );


14

这里有一个难以读懂的、使用ES6编写的不好的解决方案:

export default (arr, key) => 
  arr.reduce(
    (r, v, _, __, k = v[key]) => ((r[k] || (r[k] = [])).push(v), r),
    {}
  );

对于那些想知道这个是如何工作的人,这里有一个解释:
- 在两个“=>”中都有一个自由的“return”。 - Array.prototype.reduce函数最多可以接受4个参数。这就是为什么要添加第五个参数,以便在参数声明级别上使用默认值来实现组(k)的廉价变量声明。(是的,这是巫术) - 如果我们当前的组在上一次迭代中不存在,我们将创建一个新的空数组((r[k] || (r[k] = []))。这将评估为最左边的表达式,换句话说,就是一个现有的数组或一个空数组,这就是为什么在该表达式之后立即有一个推送的原因,因为无论哪种情况,您都会得到一个数组。 - 当有一个返回时,逗号运算符将丢弃最左边的值,并返回此场景下调整后的先前组。
更易理解的版本是:
export default (array, key) => 
  array.reduce((previous, currentItem) => {
    const group = currentItem[key];
    if (!previous[group]) previous[group] = [];
    previous[group].push(currentItem);
    return previous;
  }, {});

编辑:

TS版本:

const groupBy = <T, K extends keyof any>(list: T[], getKey: (item: T) => K) =>
  list.reduce((previous, currentItem) => {
    const group = getKey(currentItem);
    if (!previous[group]) previous[group] = [];
    previous[group].push(currentItem);
    return previous;
  }, {} as Record<K, T[]>);

2
你能解释一下这个吗?它运行得很完美。 - Nuwan Dammika
1
@NuwanDammika
  • 在两者中,您有一个免费的“返回”。
  • reduce函数最多可以使用4个参数。这就是为什么要添加第五个参数,以便我们可以对组(k)进行廉价变量声明。
  • 如果先前的值没有我们当前的组,则创建一个新的空组((r [k] ||(r [k] = []))这将评估为最左边的表达式,否则为数组或空数组,这就是为什么在该表达式之后立即进行推送的原因。
  • 当有返回时,逗号运算符将丢弃最左边的值,返回调整后的先前组。
- kevinrodriguez-io
3
TS 中最佳的语法是什么?当使用复杂对象时,最佳回答是什么? const groups = groupBy(items, (x) => x.groupKey); (翻译后):在处理复杂对象时,使用 TS 的最佳语法是什么?对于上述代码,最佳答案是保持不变,即使用 groupBy 函数将 items 按照 groupKey 进行分组,并将结果存储在 groups 变量中。 - Dmytro Sokhach
这很棒。我是一个Scala程序员,感觉像在家一样。嗯...除了默认值是干什么用的? - WestCoastProjects
@javadba export default 只是用于 JS 模块的语法,类似于普通的导出。使用 default 关键字可以让你像这样导入:import Group from '../path/to/module'; - kevinrodriguez-io

8
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;
    }, []);
}

这个函数输出一个数组。


8

我想提出我的方法。首先,分开分组和聚合。让我们声明原型的“group by”函数。它需要另一个函数来为要分组的每个数组元素生成“哈希”字符串。

Array.prototype.groupBy = function(hash){
  var _hash = hash ? hash : function(o){return o;};

  var _map = {};
  var put = function(map, key, value){
    if (!map[_hash(key)]) {
        map[_hash(key)] = {};
        map[_hash(key)].group = [];
        map[_hash(key)].key = key;

    }
    map[_hash(key)].group.push(value); 
  }

  this.map(function(obj){
    put(_map, obj, obj);
  });

  return Object.keys(_map).map(function(key){
    return {key: _map[key].key, group: _map[key].group};
  });
}

当分组完成后,您可以按照需要聚合数据,在您的情况下

data.groupBy(function(o){return JSON.stringify({a: o.Phase, b: o.Step});})
    /* aggreagating */
    .map(function(el){ 
         var sum = el.group.reduce(
           function(l,c){
             return l + parseInt(c.Value);
           },
           0
         );
         el.key.Value = sum; 
         return el.key;
    });

通常情况下它是有效的。我已经在Chrome控制台中测试了这段代码。欢迎您进行改进并找出错误 ;)


谢谢!我喜欢这种方法,它完全符合我的需求(我不需要聚合)。 - aberaud
我认为你想要在put()函数中更改以下代码行:map[_hash(key)].key = key;map[_hash(key)].key = _hash(key); - Scotty.NET
请注意,如果数组中包含与对象原型中任何函数名称相似的字符串(例如:["toString"].groupBy()),则此操作将失败。 - tigrou

8

没有变异:

const groupBy = (xs, key) => xs.reduce((acc, x) => Object.assign({}, acc, {
  [x[key]]: (acc[x[key]] || []).concat(x)
}), {})

console.log(groupBy(['one', 'two', 'three'], 'length'));
// => {3: ["one", "two"], 5: ["three"]}

8
这个解决方案可以接受任意的函数(而不是关键字),因此比上面的解决方案更加灵活,它允许使用类似于LINQ中使用的lambda表达式箭头函数。请注意保留HTML标记。
Array.prototype.groupBy = function (funcProp) {
    return this.reduce(function (acc, val) {
        (acc[funcProp(val)] = acc[funcProp(val)] || []).push(val);
        return acc;
    }, {});
};

注意: 是否要扩展Array的原型由您自行决定。

在大多数浏览器中支持的示例:

[{a:1,b:"b"},{a:1,c:"c"},{a:2,d:"d"}].groupBy(function(c){return c.a;})

使用箭头函数的示例(ES6):

[{a:1,b:"b"},{a:1,c:"c"},{a:2,d:"d"}].groupBy(c=>c.a)

以上两个示例都返回:

{
  "1": [{"a": 1, "b": "b"}, {"a": 1, "c": "c"}],
  "2": [{"a": 2, "d": "d"}]
}

我非常喜欢ES6的解决方案。只需简单地对数组原型进行扩展,就可以实现以下代码:let key = 'myKey'; let newGroupedArray = myArrayOfObjects.reduce(function (acc, val) { (acc[val[key]] = acc[val[key]] || []).push(val); return acc;}); - caneta

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