有如下這樣一組學生成績的資料,需要把 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()
那樣也是可以的,只是感覺把欄位和順序分離開有點彆扭。