11
头图

有如下这样一组学生成绩的数据,需要把 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 如何

注意 自己扩展原生类有风险,它有可能和别的扩展产生冲突。C# 和 Kotlin 等语言提供的扩展方法语法是安全的,因为使用这些扩展方法需要引入命名空间,而且可以在编译期就发现冲突。JavaScript 通过原型扩展的形式是一种覆盖的形式,安全性较低,需要特别谨慎。

如果不用扩展方法,可以自己写一套函数来处理。不过有现成的 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"])

如果还要指定顺序不审逆序,可以通过在字段名后加 ad 来指示。比如 "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() 那样也是可以的,只是感觉把字段和顺序分离开有点别扭。


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!