IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    JS 根据汇总结果过滤

    边城发表于 2023-04-06 14:18:03
    love 0

    有如下这样一组学生成绩的数据,需要把 7 年级的优秀学生(所有科目成绩大于等于 80 分)找出来,按数学成绩从大到小排序,如果数学成绩一样则按姓名排序。

    const table = [
        { "name": "张三", "grade": 8, "subject": "语文", "score": 90 },
        { "name": "张三", "grade": 8, "subject": "数学", "score": 76 },
        { "name": "张三", "grade": 8, "subject": "英语", "score": 86 },
        { "name": "李四", "grade": 7, "subject": "语文", "score": 78 },
        { "name": "李四", "grade": 7, "subject": "数学", "score": 98 },
        { "name": "李四", "grade": 7, "subject": "英语", "score": 70 },
        { "name": "王五", "grade": 8, "subject": "语文", "score": 90 },
        { "name": "王五", "grade": 8, "subject": "数学", "score": 89 },
        { "name": "王五", "grade": 8, "subject": "英语", "score": 87 },
        ...
    ];

    这里提出了两个要求,一是过滤数据,二是排序。看起来简单,似乎又不简单,为什么呢?

    过滤条件有一项是“所有科目成绩大于 80”,这是单纯的逐一判断,而是需要先聚合,再判断。而排序也不是简单的一次成型,而是双重排序。

    来看看是怎么实现的(有些方法并不存在,先从方法名的字面意思来理解)

    解决问题

    const result = data
        // 把 7 年级的学生过滤出来
        .filter(({ grade }) => grade === 7)
        // 按姓名分组,分组后是一个对象,形如 {"张三": [{}, {}, {}], "李四": [{}, {}, {}]}
        .groupBy("name")
        // 转换成 entry pair 数组,转后形如 [["张三", [{}, {}, {}]], ["李四", [{}, {}, {}]]]
        .toEntries()
        // 对 pair 的 value(即 pair[1])判断所有分数都在 80 分以上(含 80),符合条件的过滤出来
        .filter(([, its]) => its.every(({ score }) => score >= 80))
        // 找出其中数学成绩那条记录
        .map(([, its]) => its.find(({ subject }) => subject === "数学"))
        // 用例数据不存在没有数学成绩的,但是如果有,这里要用 .filter(it => it !== undefined) 过滤掉
        // 排序,先按分数从大到小排
        .sort((a, b) => a.score === b.score ? a.name.compare(b.name) : b.score - a.score);
        //                                    ^^^^^^^^^^^^^^^^^^^^^^ 分数相同比较姓名

    采用链式调用的方式来处理数据,就跟说话一样,行云流水地就写出来了。只可惜这里用到了 groupBy()、toEntries() 等方法都不存在。但是不要紧,JS 的类扩展性非常好,我们可以在原型上挂方法函数

    Array.prototype.groupBy = function (key) {
        const getKey = typeof key === "function" ? key : it => it[key];
        return this.reduce(
            (agg, it) => ((agg[getKey(it)] ??= []).push(it), agg),
            {}
        );
    };
    
    Object.prototype.toEntries = function () {
        return Object.entries(this);
    };
    

    还有一个 String 的 compare 扩展

    String.prototype.compare = function (b) {
        return this < b ? -1 : this > b ? 1 : 0;
    };

    模拟数据

    在没有现成数据的情况下,模拟数据很有必要。先上网找个在线的随机起名的网站,生成几十个名字,于是我们得到了姓名数组 names。

    每个人一定在某一个年级:

    names.map(name => ({name, grade: randInt(7, 8)}));

    每个人都有三个科目的成绩,这三个科目是 const subjects = ["语文", "数学", "英语"]。

    每个科目都有一个分数(为了更容易找到符合条件的,分数控制在 70~100):

    subjects.map(subject => ({subject, score: randInt(70, 100)}));

    randInt 当然是不存在的,需要自己写

    function randInt(min, max) {
        return min + ~~(Math.random() * (max + 1 - min));
    }

    从上面第一个 map 我们得到了一个对象,包含人以及他所在的年级。从上面第二个 map 也能得到一个对象,包含科目以及该科目的分数。两个 map 的结果是一对多的关系(一个人有 3 科成绩),所以需要使用 flatMap 来展开。所以最终模拟数据是这样生成的:

    const data = names
        .map(name => ({ name, grade: randInt(7, 8) }))
        .flatMap(student => subjects.map(
            (subject) => ({ ...student, subject, score: randInt(75, 100) })
        ));

    使用 Lodash 如何

    自己扩展原生类是有风险的,万一某个库扩展了,同名,但是参数或者行为有所不同。覆盖扩展之后很容易引起计算混乱,出现一些莫名其妙的问题(就是有问题,但又不知道在哪里)。自己写一套函数来处理当然没问题,不过有现成的 Lodash 为啥不用呢

    const result = _(data)
        .filter(({ grade }) => grade === 7)
        .groupBy("name")
        .toPairs()
        .filter(([, its]) => its.every(({ score }) => score >= 80))
        .map(([, its]) => its.find(({ subject }) => subject === "数学"))
        .orderBy(["score", "name"], ["desc", "asc"])
        .value();

    基本上和前面的代码一样。

    多思考一下

    如果不是按每一科都上 80,而是要求总分在 240 分的线上而且最低单科不得低于 75 呢?

    唔,这里要算总分,得用一个 reduce

    Array.prototype.sumBy = function (key) {
        return this.reduce((sum, { [key]: value }) => sum + value, 0);
    };

    再加上不得低于 75 的条件(和不低于 80 类似)

    .filter(([, its]) => its.sumBy("score") && its.every(({ score }) => score >= 75))

    那如果要把二重排序扩展为多重排序呢?

    那就需要自己实现一个 sort,并且传入一个属性列表来指示需要按哪些字段来排序(暂且不考虑方向)

    Array.prototype.sortBy = function (props) {
        return this.sort((a, b) => {
            for (const prop of props) {
                if (a[prop] === b[prop]) {
                    // 相等就判断下一项
                    continue;
                }
    
                // 不等则已经有结果了
                return a[prop] < b[prop] ? -1 : 1;
            }
    
            return 0;
        });
    };
    
    // 调用示例
    data.sortBy(["grade", "name", "score"])

    如果还要指定顺序不审逆序,可以通过在字段名后加 a 或 d 来指示。比如 "grade a"、"score d" 等。那么在解析的时候可以使用 split 拆分,还可以在没有指定顺序的时候默认指定为 "a":

    const [field, direct = "a"] = prop.split(/\s+/);

    但实际应用中这种方式很受限,万一属性名中含有空格呢?那我们可以把字符串指示的属性名为一个对象(同时兼容字符串默认升序),比如

    ["grade", { field: "name" }, { field: "score", desc: true }]
    Array.prototype.sortBy = function (props) {
        return this.sort((a, b) => {
            for (const prop of props) {
                const { field, desc } = typeof prop === "string" ? { field: prop } : prop;
                // 根据 desc 来判断 a 小于 b 的时候是返回 -1(升)还是 1(降)
                const smallMark = desc ? 1 : -1;
                if (a[field] === b[field]) { continue; }
                return a[field] < b[field] ? smallMark : -smallMark;
            }
    
            return 0;
        });
    };

    当然像 Lodash 的 _.orderBy() 那样也是可以的,只是感觉把字段和顺序分离开有点别扭。



沪ICP备19023445号-2号
友情链接