JS 根據彙總結果過濾

邊城發表於2023-04-06

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

相關文章