最近遇到一些同学在问 JS 中进行数据统计的问题。尽管数据统计个别会在数据库中进行,然而后端遇到须要应用程序来进行统计的状况也十分多。.NET 就为了对内存数据和数据库数据进行对立地数据处理,创造了 LINQ (Language-Integrated Query)。其实 LINQ 语法自身没什么,要害是为了实现 LINQ 而设计的表达式树、IEnumerable 和 IQueryable 的各种扩大等。

提出问题

不扯远了,先来看问题。依据上面的样例数据,要求失去

  1. 先按业务,再按部门分组的数据;
  2. 不按部门,间接按业务别离统计每年的数据
[  {    name: "部门1",    businesses: [      {        name: "产品销售",        years: [          { name: "2021", value: 132 }, { name: "2022", value: 183 }, { name: "2023", value: 207 }        ]      },      {        name: "原料洽购",        years: [          { name: "2021", value: 143 }, { name: "2022", value: 121 }, { name: "2023", value: 120 }        ]      }    ]  },  {    name: "部门2",    businesses: [      {        name: "产品销售",        years: [          { name: "2021", value: 230 }, { name: "2022", value: 112 }, { name: "2023", value: 288 }        ]      },      {        name: "原料洽购",        years: [          { name: "2021", value: 168 }, { name: "2022", value: 203 }, { name: "2023", value: 115 }        ]      }    ]  },  {    name: "部门3",    businesses: [      {        name: "产品销售",        years: [          { name: "2021", value: 279 }, { name: "2022", value: 163 }, { name: "2023", value: 271 }        ]      },      {        name: "原料洽购",        years: [          { name: "2021", value: 129 }, { name: "2022", value: 121 }, { name: "2023", value: 226 }        ]      }    ]  }];

这个数据,如果用金山文档的轻维表(飞书多维表相似)来查看,会更直观

原数据(按部门再按业务)的轻维表出现

按业务再按部门分组的轻维表出现

按业务按年统计的轻维表出现

展平多级数据

原数据按部门再按业务进行了两级分类,所以它不是简略的二维表(行/列)数据,而是在二维表的根底上减少了两个维度(部门/业务)。从要求来看,咱们须要的是从另外的维度(业务/部门,业务/年度)来进行解决。所以须要先把这些数据降维开展成能够从新划分维度的水平,也就是二维表。

JS 中二维表的示意办法挺多,行对象汇合是最常见的一种,这里咱们也就采纳这种示意办法。

还有一种常见的形式是列汇合+行汇合,其中行汇合能够是对象示意(字段名对应)也能够是数组示意(索引号对应)。不过这种示意一会是用在 UI 中。单纯数据处理用行对象汇合就够了,不须要独自的列信息。

察看原数据的每一级,发现名称都命名为 name,然而子集命名各不相同,层级无限。因为对每一层须要去解决名称到列(对象属性名)的转换,也须要对不同名称的子集进行进一步解决,各层级之间不足不言而喻的共性,不太适宜递归的形式来解决。所以咱们定做一个开展函数。

上面是对原数据量身定做的开展函数,开展后会失去一个蕴含部门 (dept)、业务 (business)、年份 (year)、数值 (value) 四个属性的对象汇合。

function flatBusinesses(list) {    return list.flatMap(({ name: dept, businesses }) => {        return businesses.flatMap(({ name: business, years }) => {            return years.map(({ name: year, value }) => ({                dept,                business,                year,                value            }));        });    });}
升级:如果想用递归该怎么解决?

并不是多级开展就肯定会用到递归。比方规定的数组构造,比方规定的树结构,是能够应用递归遍历开展的。然而像这个案例的数据,每一层的子级属性名称都不同,层级无限,须要逐级解决。

如果切实想用递归的话,也能够通过一个参数来定义每一级的解决规定。以这个例子来说,每一级要解决两件事:① 找到子级节点属性名;② 将 name 解决成适当的名称用在开展的数据中。

function flatMultiLevelList(list, rules) {    return flatList(list, 0);    function flatList(list, level) {        const rule = rules[level];        if (!rule) { return [{}]; }        // 获得 field(子级属性名)和 convert(属性处理器)        // 如果没有 convert 则指定一个默认的 it => it,即不做转换        const { field, convert = it => it } = rule;        if (field) {            // 如果存在子级,则持续 flatMap,展平。            // ❶ { fff, ...others } 能够将 fff 属性从原对象中剥离进去            // ❷ { [feild]: nodes } 解构能够将 field 的值所指向的属性取出来赋予一个叫 nodes 的变量            return list.flatMap(({ [field]: nodes, ...props }) => {                return flatList(nodes, level + 1).map(it => ({ ...convert(props), ...it }));            });        } else {            // 如果不存在子级,只须要对以后节点进行转换,间接返回即可            return list.map(it => convert(it));        }    }}

开展后会拿到这样的数据(假如赋值变量 table

[    { "dept": "部门1", "business": "产品销售", "year": 2021, "value": 132 },    { "dept": "部门1", "business": "产品销售", "year": 2022, "value": 183 },    { "dept": "部门1", "business": "产品销售", "year": 2023, "value": 207 },    { "dept": "部门1", "business": "原料洽购", "year": 2021, "value": 143 },    { "dept": "部门1", "business": "原料洽购", "year": 2022, "value": 121 },    { "dept": "部门1", "business": "原料洽购", "year": 2023, "value": 120 },    { "dept": "部门2", "business": "产品销售", "year": 2021, "value": 230 },    { "dept": "部门2", "business": "产品销售", "year": 2022, "value": 112 },    ...]

拿到二维表之后,某些须要的数据或视图就能够通过电子表格来取得。比方问题一中须要的统计数据,应用电子表格的透视图性能就能实现,而金山文档的轻维表,或者飞书的多维表能够实现得更容易。不过咱们当初须要用代码来实现。

分类及分类汇总

第一个问题的需要是分类和分类汇总。说到分类,那首先想到的必定是 group 操作。很惋惜原生 JS 不反对 group,如果想用现成的,能够思考 Lodash,要本人写一个倒也不难。group 操作后面提到的开展操作的逆操作。

function groupBy(list, key) {    // 这里简略地兼容一下传入 key 值和 keyGetter 的状况    const getKey = typeof key === "function" ? key : it => it[key];    return list.reduce(        (groups, it) => {            (groups[getKey(it)] ??= []).push(it);            return groups;        },        {}  // 空对象作为初始 groups    );}

按业务再按部门分组

有了 groupBy,能够先按业务进行分组

// 后面假如展平的数据寄存在变量 table 中const groups = groupBy(table, "dept");

当初咱们拿到的 byDept 是一个 JS 对象(留神不是数组哦),其键是部门名称,值是一个数组,蕴含该部门下的所有数据。接下来进行第二层分组,是须要对 byDept 的每一个“值”进行分组解决。

for (const key in groups) {    const list = groups[key];    groups[key] = groupBy(list, "business");}

解决之后的 groups 长得像这样

{    "产品销售": {        "部门1": [            { dept: "部门1", business: "产品销售", year: "2021", value: 132 },            ...        ],        "部门2": [            { dept: "部门2", business: "产品销售", year: "2021", value: 230 },            ...        ],        "部门3": ...    },    "原料洽购": ...}

后果是拿到了,然而和合乎原始的数据标准(原始层级每层是用 name 属性作为字段名,子级命名各不相同)所以还须要做一次转换。比方第一层的转换是这样:

const converted = Object.entries(groups)    .map(([name, depts]) => ({ name, depts }));

它会把第一层(对象)解决成数组,每个元素蕴含 namedepts 两个属性,name 属性是名称,depts 则是按部门分组的后果(目前还是对象)。那么第二、三层转换也相似。把后面的分组和前面的转换合并起来,是这样

const result1 = Object.entries(groupBy(table, "business"))    .map(([name, list]) => ({        name,        depts: Object.entries(groupBy(list, "dept"))            .map(([name, list]) => ({                name,                years: list.map(({ year: name, value }) => ({ name, value }))            }))    }));

失去最终后果

[  {    name: "产品销售",    depts: [      {        name: "部门1",        years: [{ name: "2021", value: 132 }, { name: "2022", value: 183 }, { name: "2023", value: 207 }]      },      {        name: "部门2",        years: [{ name: "2021", value: 230 }, { name: "2022", value: 112 }, { name: "2023", value: 288 }]      },      {        name: "部门3",        years: [{ name: "2021", value: 279 }, { name: "2022", value: 163 }, { name: "2023", value: 271 }]      }    ]  },  ...]

按业务分组再按年统计

对于第一个问题的第二个需要,要按年统计业务(疏忽部门),解决办法与下面的办法类型。第二层分组改为按年份,而不是按部门;同时第二层的数组转换时不再转换第三层的数据,而是对第三层数据进行汇总。

const result2 = Object.entries(groupBy(table, "business"))    .map(([name, list]) => ({        name,        years: Object.entries(groupBy(list, "year"))//      ^^^^^                               ^^^^^^ 按年分组            .map(([name, list]) => ({                name,                value: list.reduce((sum, { value }) => sum + value, 0)//              ^^^^^ 间接取值,应用 reduce 汇总            }))    }));

后果(用后面做的轻维表统计来核查一下,完全正确)

[  {    name: "产品销售",    years: [{ name: "2021", value: 641 }, { name: "2022", value: 458 }, { name: "2023", value: 766 }]  },  {    name: "原料洽购",    years: [{ name: "2021", value: 440 }, { name: "2022", value: 445 }, { name: "2023", value: 461 }]  }]

如果用 Lodash 会怎么写

用 Lodash 来解决代码构造看起来更清晰一些,但代码量不见得少。

开展的局部用 Lodash 和应用原生办法没什么区别,都是应用 flatMap。Lodash 提供的 flatMapDeep 能够用来开展纯正的多级数组,但在这里不实用,因为每一级都不是单纯的开展,而是要进行独自的映射解决。Lodash 的 flatMapDeep 更像是原生的 map().flat(Number.MAX_SAFE_INTEGER)

const result1 = _(table)    // groupBy 的后果是一个对象,属性名是组名,属性值是组内数据列表。    .groupBy("business")    // 第一种解决值集的办法,先把值解决了 (mapValues),再来解决键值对 (map)    .mapValues(depts => _(depts)        .groupBy("dept")        // 第二种解决值集的办法,解决键值对的时候,同时解决值汇合        .map((values, name) => ({            name,            years: values.map(({ year: name, value }) => ({ name, value }))        }))        .value()    )    .map((depts, name) => ({ name, depts }))    .value();
const result2 = _(table).groupBy("business")    .map((list, name) => ({        name,        years: _(list).groupBy("year")            .map((list, name) => ({                name,                value: _.sumBy(list, "value")            }))            .value()    }))    .value();

小结

如果须要对某个数据进行分类或者分类汇总,首先得拿到这个数据的二维表,也就是齐全开展的数据列表。少数状况下从后端拿到的数据都是二维表,毕竟关系型数据库逻辑构造是表存储。接下来所谓的“分类”其实就是分组操作,而“汇总”就是把分类后的子列表拿来进行聚合计算(计数、共计、均匀、最大/小等都是聚合计算),失去最终的后果。