有如下这样一组学生成绩的数据,需要把 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"])
如果还要指定顺序不审逆序,可以通过在字段名后加 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()
那样也是可以的,只是感觉把字段和顺序分离开有点别扭。