最近遇到一些同學在問 JS 中進行資料統計的問題。雖然資料統計一般會在資料庫中進行,但是後端遇到需要使用程式來進行統計的情況也非常多。.NET 就為了對記憶體資料和資料庫資料進行統一地資料處理,發明了 LINQ (Language-Integrated Query)。其實 LINQ 語法本身沒什麼,關鍵是為了實現 LINQ 而設計的表示式樹、IEnumerable 和 IQueryable 的各種擴充套件等。
提出問題
不扯遠了,先來看問題。根據下面的樣例資料,要求得到
- 先按業務,再按部門分組的資料;
- 不按部門,直接按業務分別統計每年的資料
[
{
name: "部門1",
businesses: [
{
name: "產品銷售",
years: [
{ name: "2021", value: 132 }, { name: "2022", value: 183 }, { name: "2023", value: 207 }
]
},
{
name: "原料採購",
years: [
{ name: "2021", value: 143 }, { name: "2022", value: 121 }, { name: "2023", value: 120 }
]
}
]
},
{
name: "部門2",
businesses: [
{
name: "產品銷售",
years: [
{ name: "2021", value: 230 }, { name: "2022", value: 112 }, { name: "2023", value: 288 }
]
},
{
name: "原料採購",
years: [
{ name: "2021", value: 168 }, { name: "2022", value: 203 }, { name: "2023", value: 115 }
]
}
]
},
{
name: "部門3",
businesses: [
{
name: "產品銷售",
years: [
{ name: "2021", value: 279 }, { name: "2022", value: 163 }, { name: "2023", value: 271 }
]
},
{
name: "原料採購",
years: [
{ name: "2021", value: 129 }, { name: "2022", value: 121 }, { name: "2023", value: 226 }
]
}
]
}
];
這個資料,如果用金山文件的輕維表(飛書多維表類似)來檢視,會更直觀
原資料(按部門再按業務)的輕維表呈現
按業務再按部門分組的輕維表呈現
按業務按年統計的輕維表呈現
展平多級資料
原資料按部門再按業務進行了兩級分類,所以它不是簡單的二維表(行/列)資料,而是在二維表的基礎上增加了兩個維度(部門/業務)。從要求來看,我們需要的是從另外的維度(業務/部門,業務/年度)來進行處理。所以需要先把這些資料降維展開成可以重新劃分維度的程度,也就是二維表。
JS 中二維表的表示方法挺多,行物件集合是最常見的一種,這裡我們也就採用這種表示方法。
還有一種常見的方式是列集合+行集合,其中行集合可以是物件表示(欄位名對應)也可以是陣列表示(索引號對應)。不過這種表示一會是用在 UI 中。單純資料處理用行物件集合就夠了,不需要單獨的列資訊。
觀察原資料的每一級,發現名稱都命名為 name
,但是子集命名各不相同,層級有限。由於對每一層需要去處理名稱到列(物件屬性名)的轉換,也需要對不同名稱的子集進行進一步處理,各層級之間缺乏顯而易見的共性,不太適合遞迴的方式來處理。所以我們定做一個展開函式。
下面是對原資料量身定做的展開函式,展開後會得到一個包含部門 (dept)、業務 (business)、年份 (year)、數值 (value) 四個屬性的物件集合。
function flatBusinesses(list) {
return list.flatMap(({ name: dept, businesses }) => {
return businesses.flatMap(({ name: business, years }) => {
return years.map(({ name: year, value }) => ({
dept,
business,
year,
value
}));
});
});
}
晉級:如果想用遞迴該怎麼處理?
並不是多級展開就一定會用到遞迴。比如規則的陣列結構,比如規則的樹結構,是可以使用遞迴遍歷展開的。但是像這個案例的資料,每一層的子級屬性名稱都不同,層級有限,需要逐級處理。
如果實在想用遞迴的話,也可以透過一個引數來定義每一級的處理規則。以這個例子來說,每一級要處理兩件事:① 找到子級節點屬性名;② 將
name
處理成適當的名稱用在展開的資料中。function flatMultiLevelList(list, rules) { return flatList(list, 0); function flatList(list, level) { const rule = rules[level]; if (!rule) { return [{}]; } // 取得 field(子級屬性名)和 convert(屬性處理器) // 如果沒有 convert 則指定一個預設的 it => it,即不做轉換 const { field, convert = it => it } = rule; if (field) { // 如果存在子級,則繼續 flatMap,展平。 // ❶ { fff, ...others } 可以將 fff 屬性從原物件中剝離出來 // ❷ { [feild]: nodes } 解構可以將 field 的值所指向的屬性取出來賦予一個叫 nodes 的變數 return list.flatMap(({ [field]: nodes, ...props }) => { return flatList(nodes, level + 1).map(it => ({ ...convert(props), ...it })); }); } else { // 如果不存在子級,只需要對當前節點進行轉換,直接返回即可 return list.map(it => convert(it)); } } }
展開後會拿到這樣的資料(假設賦值變數 table
)
[
{ "dept": "部門1", "business": "產品銷售", "year": 2021, "value": 132 },
{ "dept": "部門1", "business": "產品銷售", "year": 2022, "value": 183 },
{ "dept": "部門1", "business": "產品銷售", "year": 2023, "value": 207 },
{ "dept": "部門1", "business": "原料採購", "year": 2021, "value": 143 },
{ "dept": "部門1", "business": "原料採購", "year": 2022, "value": 121 },
{ "dept": "部門1", "business": "原料採購", "year": 2023, "value": 120 },
{ "dept": "部門2", "business": "產品銷售", "year": 2021, "value": 230 },
{ "dept": "部門2", "business": "產品銷售", "year": 2022, "value": 112 },
...
]
拿到二維表之後,某些需要的資料或檢視就可以透過電子表格來獲得。比如問題一中需要的統計資料,使用電子表格的透檢視功能就能實現,而金山文件的輕維表,或者飛書的多維表可以實現得更容易。不過我們現在需要用程式碼來實現。
分類及分類彙總
第一個問題的需求是分類和分類彙總。說到分類,那首先想到的肯定是 group 操作。很可惜原生 JS 不支援 group,如果想用現成的,可以考慮 Lodash,要自己寫一個倒也不難。group 操作前面提到的展開操作的逆操作。
function groupBy(list, key) {
// 這裡簡單地相容一下傳入 key 值和 keyGetter 的情況
const getKey = typeof key === "function" ? key : it => it[key];
return list.reduce(
(groups, it) => {
(groups[getKey(it)] ??= []).push(it);
return groups;
},
{} // 空物件作為初始 groups
);
}
按業務再按部門分組
有了 groupBy
,可以先按業務進行分組
// 前面假設展平的資料存放在變數 table 中
const groups = groupBy(table, "dept");
現在我們拿到的 byDept
是一個 JS 物件(注意不是陣列哦),其鍵是部門名稱,值是一個陣列,包含該部門下的所有資料。接下來進行第二層分組,是需要對 byDept
的每一個“值”進行分組處理。
for (const key in groups) {
const list = groups[key];
groups[key] = groupBy(list, "business");
}
處理之後的 groups
長得像這樣
{
"產品銷售": {
"部門1": [
{ dept: "部門1", business: "產品銷售", year: "2021", value: 132 },
...
],
"部門2": [
{ dept: "部門2", business: "產品銷售", year: "2021", value: 230 },
...
],
"部門3": ...
},
"原料採購": ...
}
結果是拿到了,但是和符合原始的資料規範(原始層級每層是用 name
屬性作為欄位名,子級命名各不相同)所以還需要做一次轉換。比如第一層的轉換是這樣:
const converted = Object.entries(groups)
.map(([name, depts]) => ({ name, depts }));
它會把第一層(物件)處理成陣列,每個元素包含 name
和 depts
兩個屬性,name
屬性是名稱,depts
則是按部門分組的結果(目前還是物件)。那麼第二、三層轉換也類似。把前面的分組和後面的轉換合併起來,是這樣
const result1 = Object.entries(groupBy(table, "business"))
.map(([name, list]) => ({
name,
depts: Object.entries(groupBy(list, "dept"))
.map(([name, list]) => ({
name,
years: list.map(({ year: name, value }) => ({ name, value }))
}))
}));
得到最終結果
[
{
name: "產品銷售",
depts: [
{
name: "部門1",
years: [{ name: "2021", value: 132 }, { name: "2022", value: 183 }, { name: "2023", value: 207 }]
},
{
name: "部門2",
years: [{ name: "2021", value: 230 }, { name: "2022", value: 112 }, { name: "2023", value: 288 }]
},
{
name: "部門3",
years: [{ name: "2021", value: 279 }, { name: "2022", value: 163 }, { name: "2023", value: 271 }]
}
]
},
...
]
按業務分組再按年統計
對於第一個問題的第二個需求,要按年統計業務(忽略部門),處理方法與上面的方法型別。第二層分組改為按年份,而不是按部門;同時第二層的陣列轉換時不再轉換第三層的資料,而是對第三層資料進行彙總。
const result2 = Object.entries(groupBy(table, "business"))
.map(([name, list]) => ({
name,
years: Object.entries(groupBy(list, "year"))
// ^^^^^ ^^^^^^ 按年分組
.map(([name, list]) => ({
name,
value: list.reduce((sum, { value }) => sum + value, 0)
// ^^^^^ 直接取值,使用 reduce 彙總
}))
}));
結果(用前面做的輕維表統計來核對一下,完全正確)
[
{
name: "產品銷售",
years: [{ name: "2021", value: 641 }, { name: "2022", value: 458 }, { name: "2023", value: 766 }]
},
{
name: "原料採購",
years: [{ name: "2021", value: 440 }, { name: "2022", value: 445 }, { name: "2023", value: 461 }]
}
]
如果用 Lodash 會怎麼寫
用 Lodash 來處理程式碼結構看起來更清晰一些,但程式碼量不見得少。
展開的部分用 Lodash 和使用原生方法沒什麼區別,都是使用 flatMap。Lodash 提供的 flatMapDeep 可以用來展開純粹的多級陣列,但在這裡不適用,因為每一級都不是單純的展開,而是要進行單獨的對映處理。Lodash 的 flatMapDeep 更像是原生的 map().flat(Number.MAX_SAFE_INTEGER)
。
const result1 = _(table)
// groupBy 的結果是一個物件,屬性名是組名,屬性值是組內資料列表。
.groupBy("business")
// 第一種處理值集的方法,先把值處理了 (mapValues),再來處理鍵值對 (map)
.mapValues(depts => _(depts)
.groupBy("dept")
// 第二種處理值集的方法,處理鍵值對的時候,同時處理值集合
.map((values, name) => ({
name,
years: values.map(({ year: name, value }) => ({ name, value }))
}))
.value()
)
.map((depts, name) => ({ name, depts }))
.value();
const result2 = _(table).groupBy("business")
.map((list, name) => ({
name,
years: _(list).groupBy("year")
.map((list, name) => ({
name,
value: _.sumBy(list, "value")
}))
.value()
}))
.value();
小結
如果需要對某個資料進行分類或者分類彙總,首先得拿到這個資料的二維表,也就是完全展開的資料列表。多數情況下從後端拿到的資料都是二維表,畢竟關係型資料庫邏輯結構是表儲存。接下來所謂的“分類”其實就是分組操作,而“彙總”就是把分類後的子列表拿來進行聚合計算(計數、合計、平均、最大/小等都是聚合計算),得到最終的結果。