如何 reduce/transform 一个对象数组以得到每个 属性 的总和(最好是 lodash)

How to reduce/transform an array of objects to result in the aggregate sum of each property (lodash ideally)

我有一组看起来像这样的营养对象:

[
  {
    calories: {total: 0, fat: 0},
    vitamins: {a: {total: 0, retinol: 0}, b6: 0, c: 0},
    fats: {total: 0},
    minerals: {calcium: 0}
  },
  {
    calories: {total: 150, fat: 40},
    vitamins: {a: {total: 100}, b6: 30, c: 200},
    fats: {total: 3}
  },
  {
    calories: {total: 100, fat: 60},
    vitamins: {a: {total: 120, retinol: 10}, b6: 0, c: 200},
    minerals: {calcium: 20}
  }
]

请注意,我添加了一个 0 填充对象,因此没有属性为 null 或未定义;并非所有属性都存在于后续对象上;这是一个非常深的对象(即 obj.vitamins.a.total)

结果应该是

{
  calories: {total: 250, fat: 100},
  vitamins: {a: {total: 220, retinol: 10}, b6: 30, c: 400},
  fats: {total: 3},
  minerals: {calcium: 20}
}

我知道 lodash_.reduce()_.transform() 函数,但我不确定如何访问 accumulator 上的属性。 我怎样才能达到预期的结果,最好使用函数式编程和 lodash。

我可以给你一个灵感来自 Monoids 的答案。在不深入研究范畴论的情况下,Monoid 的工作需要两件事

  • 表示您的 Monoid 类型 empty(或 neutral)的某个值
  • 以及一些将您的类型的两个 Monoid 组合在一起的函数

和幺半群

首先,让我们看看 Sum 幺半群。在 JavaScript 中有无数种方法可以实现这一点,但我们只看这个,这样您就可以大致了解

const Sum = x => ({
  constructor: Sum,
  value: x,
  concat: ({value:y}) => Sum(x + y),
  fold: f => f (x)
})

Sum.empty = Sum(0)

Sum(3).concat(Sum(4)).fold(x => console.log(x))                // 7
Sum(3).concat(Sum(4)).concat(Sum(9)).fold(x => console.log(x)) // 16


下一级幺半群

使用 Sum 幺半群,我们可以轻松组合数字,但如果我们的数据更复杂呢?例如,如果我们的数据是一个对象呢?

假设我们有 xy 并希望合理地组合它们

let x = SumObject({ a: Sum(1), b: Sum(2) })
let y = SumObject({ b: Sum(3), c: Sum(4) })

x.concat(y)
// => SumObject({ a: Sum(1), b: Sum(5), c: Sum(4) })

那就太好了!现在让我们实现 SumObject 幺半群 - 抱歉,我真的想不出更好的名字了!

const SumObject = x => ({
  constructor: SumObject,
  value: x,
  concat: ({value:y}) => {
    return SumObject(Object.keys(y).reduce((acc, k) => {
      if (acc[k] === undefined)
        return Object.assign(acc, { [k]: y[k] })
      else
        return Object.assign(acc, { [k]: <b>acc[k].concat(y[k])</b> })
    }, Object.assign({}, x)))
  },
  fold: f => f(x)
})

SumObject.empty = SumObject({})

我的天啊!那个有点难,但我希望你能看到它。注意 粗体 代码。我们对 SumObject 的实现假设 属性 也将是幺半群——这意味着值将具有 .concat 方法。


让我们一起健身吧!

为了让幺半群能够处理您的数据,我们需要将您的原始 ObjectNumber 类型转换为相应的 Monoid 类型。

最后,为了得到原始类型的答案,我们必须从 Monoid 类型返回转换为原始类型。

如果一切顺利,生成的表达式将如下所示

<b>sumToPrimitive</b>(data.map(x =>
  <b>primitiveToSum</b>(x)).reduce((acc, x) =>
    acc.concat(x), SumObject.empty))

现在让我们制作将原语转换为 Monoid 类型的函数

const primitiveToSum = x => {
  switch (x.constructor) {
    case Object:
      return SumObject(Object.keys(x).reduce((acc, k) =>
        Object.assign(acc, { [k]: primitiveToSum(x[k]) }), {}))
    case Number:
      return Sum(x)
    default:
      throw Error(`unsupported type: ${x}`)
  }
}

然后我们将制作一个将 Monoid 转换回原始类型

const sumToPrimitive = x => {
  switch (x.constructor) {
    case SumObject:
      return x.fold(x =>
        Object.keys(x).reduce((acc, k) =>
          Object.assign(acc, { [k]: sumToPrimitive(x[k]) }), {}))
    case Sum:
      return x.fold(identity)
    default:
      throw Error(`unsupported type: ${x}`)
  }
}

"So that's it?"

好吧,因为您支持 嵌套 对象,它有点混乱,但恐怕这真的是我们能做的最好的事情。如果您愿意将数据限制为平面对象,事情会更清晰。

尽管如此,让我们看看这一切是否正常

const SumObject = x => ({
  constructor: SumObject,
  value: x,
  concat: ({value:y}) => {
    return SumObject(Object.keys(y).reduce((acc, k) => {
      if (acc[k] === undefined)
        return Object.assign(acc, { [k]: y[k] })
      else
        return Object.assign(acc, { [k]: acc[k].concat(y[k]) })
    }, Object.assign({}, x)))
  },
  fold: f => f(x)
})

SumObject.empty = SumObject({})

const Sum = x => ({
  constructor: Sum,
  value: x,
  concat: ({value:y}) => Sum(x + y),
  fold: f => f (x)
})

Sum.empty = Sum(0)

const primitiveToSum = x => {
  switch (x.constructor) {
    case Object:
      return SumObject(Object.keys(x).reduce((acc, k) =>
        Object.assign(acc, { [k]: primitiveToSum(x[k]) }), {}))
    case Number:
      return Sum(x)
    default:
      throw Error(`unsupported type: ${x}`)
  }
}

const sumToPrimitive = x => {
  switch (x.constructor) {
    case SumObject:
      return x.fold(x =>
        Object.keys(x).reduce((acc, k) =>
          Object.assign(acc, { [k]: sumToPrimitive(x[k]) }), {}))
    case Sum:
      return x.fold(identity)
    default:
      throw Error(`unsupported type: ${x}`)
  }
}

const identity = x => x

const data = [
  {
    calories: {total: 0, fat: 0},
    vitamins: {a: {total: 0, retinol: 0}, b6: 0, c: 0},
    fats: {total: 0},
    minerals: {calcium: 0}
  },
  {
    calories: {total: 150, fat: 40},
    vitamins: {a: {total: 100}, b6: 30, c: 200},
    fats: {total: 3}
  },
  {
    calories: {total: 100, fat: 60},
    vitamins: {a: {total: 120, retinol: 10}, b6: 0, c: 200},
    minerals: {calcium: 20}
  }
]

const result = sumToPrimitive(data.map(x =>
  primitiveToSum(x)).reduce((acc, x) =>
    acc.concat(x), SumObject.empty))

console.log(result)


输出

{ calories: { total: 250, fat: 100 },
  vitamins: { a: { total: 220, retinol: 10 }, b6: 30, c: 400 },
  fats: { total: 3 },
  minerals: { calcium: 20 } }

备注

幺半群是维护泛型、可重用组合术语的一种很好的接口——这就是函数式编程的全部意义所在。但是,由于您的数据嵌套复杂(并且本机 JavaScript 缺少自定义类型),需要您预先进行更多手动编码。

我无法想象 Lodash 解决方案会是什么样子——但我可以告诉你的是,它会抛弃泛型和可重用性 window——尤其是考虑到你的数据结构。

您可以使用 _.mergeWith() 来完成。我正在使用 _.spread() 创建一个 _.mergeWith() 来处理数组而不是单个参数。

var data = [{"calories":{"total":0,"fat":0},"vitamins":{"a":{"total":0,"retinol":0},"b6":0,"c":0},"fats":{"total":0},"minerals":{"calcium":0}},{"calories":{"total":150,"fat":40},"vitamins":{"a":{"total":100},"b6":30,"c":200},"fats":{"total":3}},{"calories":{"total":100,"fat":60},"vitamins":{"a":{"total":120,"retinol":10},"b6":0,"c":200},"minerals":{"calcium":20}}];

var mergeWith = _.spread(_.mergeWith);

function deepMerge(objs) {
  var args = [{}].concat(objs, function(objValue, srcValue) {
      if(_.isNumber(objValue)) {
        return objValue + srcValue;
      }
    });
  
  return mergeWith(args);
}

var result = deepMerge(data);

console.log(result);
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>

使用spread语法的ES6版本:

var data = [{"calories":{"total":0,"fat":0},"vitamins":{"a":{"total":0,"retinol":0},"b6":0,"c":0},"fats":{"total":0},"minerals":{"calcium":0}},{"calories":{"total":150,"fat":40},"vitamins":{"a":{"total":100},"b6":30,"c":200},"fats":{"total":3}},{"calories":{"total":100,"fat":60},"vitamins":{"a":{"total":120,"retinol":10},"b6":0,"c":200},"minerals":{"calcium":20}}];

function deepMerge(objs) {
  return _.mergeWith({}, ...objs, (objValue, srcValue) => {
    if (_.isNumber(objValue)) {
      return objValue + srcValue;
    }
  });
}

var result = deepMerge(data);

console.log(result);
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>

根据,我们还可以将定制器提取为外部方法,因此您可以决定非对象值的合并方式。

var data = [{"calories":{"total":0,"fat":0},"vitamins":{"a":{"total":0,"retinol":0},"b6":0,"c":0},"fats":{"total":0},"minerals":{"calcium":0}},{"calories":{"total":150,"fat":40},"vitamins":{"a":{"total":100},"b6":30,"c":200},"fats":{"total":3}},{"calories":{"total":100,"fat":60},"vitamins":{"a":{"total":120,"retinol":10},"b6":0,"c":200},"minerals":{"calcium":20}}];

var mergeWith = _.spread(_.mergeWith);

function customizer(objValue, srcValue) {
  if(_.isNumber(objValue)) {
    return objValue + srcValue;
  }
}

function deepMerge(objs, customizer) {
  var args = [{}].concat(objs, function(objValue, srcValue) {
      return customizer(objValue, srcValue);
    });
  
  return mergeWith(args);
}

var result = deepMerge(data, customizer);

console.log(result);
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>

我更喜欢用 Ramda 以无点方式做这件事,但 OP 在 Lodash 中请求,所以我们开始吧,简单的递归解决方案:

let data = [{calories: {total: 0, fat: 0},vitamins: {a: {total: 0, retinol: 0}, b6: 0, c: 0},fats: {total: 0},minerals: {calcium: 0}},{calories: {total: 150, fat: 40},vitamins: {a: {total: 100}, b6: 30, c: 200},fats: {total: 3}},{calories: {total: 100, fat: 60},vitamins: {a: {total: 120, retinol: 10}, b6: 0, c: 200},minerals: {calcium: 20}}];

        const recursor = result => (value, key) => {
            if ( _.isNumber(value) ) {
                result[key] = ((result[key] || (result[key] = 0)) + value);
                return result[key];
            } else {
                return  _.mapValues(value, recursor(result[key] || (result[key] = {}) ));
            }
        }

        const reducer = (result, value, key) => _.mapValues(value, recursor(result));

        let final = _.transform(data, reducer, {});
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>

虽然 already specified the direction of using mergeWith,但他的用法在于创建一个递归调用mergeWith的递归函数。该解决方案忽略了 mergeWith 本质上是递归合并的事实,其中定制程序只会转换结果值,如果它 returns 不是 undefined 的任何值。此外,另一个问题是 mergeWith 改变了第一个对象参数,这在您想要保留源对象状态的情况下可能并不理想。

以下解决方案使用Function.prototype.apply with mergeWith instead of wrapping it in a spread,您可以根据自己的喜好决定将其切换为spread。为了解决源对象突变的问题,我们提供一个空对象作为第一个参数,然后将它与其余的源对象连接起来。最后,我们连接自定义程序函数,如果 value 是一个数字,returns 参数的总和。

var result = _.mergeWith.apply(
  null, 
  [{}].concat(source).concat(function(value, src) {
    if(_.isNumber(value)) {
      return value + src;
    }
  })
);

var source = [
  {
    calories: {total: 0, fat: 0},
    vitamins: {a: {total: 0, retinol: 0}, b6: 0, c: 0},
    fats: {total: 0},
    minerals: {calcium: 0}
  },
  {
    calories: {total: 150, fat: 40},
    vitamins: {a: {total: 100}, b6: 30, c: 200},
    fats: {total: 3}
  },
  {
    calories: {total: 100, fat: 60},
    vitamins: {a: {total: 120, retinol: 10}, b6: 0, c: 200},
    minerals: {calcium: 20}
  }
];

var result = _.mergeWith.apply(
  null, 
  [{}].concat(source).concat(function(value, src) {
    if(_.isNumber(value)) {
      return value + src;
    }
  })
);

console.log(result);
body>div {
  min-height: 100%;
  top: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.js"></script>