JavaScript – Object.groupBy & Map.groupBy
前言
group by 是一个很常见的功能,但 JS 却没有 build-in 的方法,一直到 es2024 才有 Object.groupBy (前生是 Array.prototype.group) 和 Map.groupBy (前生是 Array.prototype.groupToMap)。
目前所有 modern browser 都支持了这个功能。
如果想兼容 IOS 17.4 以下,可以使用 core-js polyfill。
参考
Stack Overflow – Most efficient method to groupby on an array of objects (用 reduce 实现的 group by, 也是最 popular 的方案)
Setup Polyfill
我需要兼容 IOS,所以例子我就搭配 core-js 呗。
yarn add core-js
然后 import
import 'core-js/actual/map/group-by';
import 'core-js/actual/object/group-by';
这样就可以了
Object.groupBy
Object.groupBy 是一个静态方法,它的调用接口长这样
groupBy<K extends string | number | symbol, T>( items: Iterable<T>, keySelector: (item: T, index: number) => K, ): Partial<Record<K, T[]>>;
groupBy 的用法很简单, 给一个 item array,配上一个 select key 函数,返回一个 key,它会把相同 key 的 item group 在一起。
最终返回一个对象 (non-prototype object),对象的 key 就是 group by 的 key,value 则是相同 key 的所有 items。
注:key 的类型必须是 string | symbol | number,其它的最好不要,如果 select key 函数返回的不是 string | symbol 会自动被强转成 string。
例子:group by name
const items = [ { name: 'Derrick', age: 1 }, { name: 'Peter', age: 1 }, { name: 'Derrick', age: 2 }, { name: 'Peter', age: 2 }, ]; const result = Object.groupBy(items, item => item.name); console.log(JSON.stringify(result,
结果
Map.groupBy
groupBy<K, T>( items: Iterable<T>, keySelector: (item: T, index: number) => K, ): Map<K, T[]>;
它和 Object.groupBy 差不多,只是 group key 不强制要求必须是 string | number | symbol,group key 可以是任何类型,而且不会自动强转成 string。
它之所以可以是任何类型是因为 Map.groupBy 返回的结果不是 Object,而是 Map,Map 的 key 可以是任何类型。
const items = [ { name: 'Derrick', age: 1 }, { name: 'Peter', age: 1 }, { name: 'Derrick', age: 2 }, { name: 'Peter', age: 2 }, ]; const result = Map.groupBy(items, item => item.name); for (const [key, value] of result) { // 1. ['Derrick', [{ name: 'Derrick', age: 1 }, { name: 'Derrick', age: 2 }]] // 2. ['Peter', [{ name: 'Peter', age: 1 }, { name: 'Peter', age: 2 }]] console.log([key, value]); }
How It Group?
group by 的关键之一就是 group key 的 comparison。
比如我 fetch 一些资料,然后想 group by Date。
如果使用 Object.groupBy 的话,它会先把 Date 转成 string 然后放入对象的 key (利用对象 key unique 特性来 group,可以理解为 key1 === key2 就 group 在一起)
如果是 Map.groupBy 则是放入 Map 的 Key,这里和 Object.groupBy 有一个微小的区别,它不会把 Date 强转成 string,所以 key1 === key2 对比的是 Date object 而不是 Date string。
通常,我们的直觉会认为是,相同的 date time value group 在一起,而不是相同指针 group 在一起,这样用 Map.groupBy 的结果就是错误的。
const today1 = new Date(2023, 0, 26); const today2 = new Date(2023, 0, 26); const items = [ { date: today1, age: 1 }, { date: today1, age: 1 }, { date: today2, age: 2 }, ]; const result1 = Object.groupBy(items, item => item.date as unknown as string); console.log(Object.keys(result1).length); // 1 const result2 = Map.groupBy(items, item => item.date); console.log([...result2.keys()].length); // 2
所以在使用 Object.groupBy 或 Map.groupBy 时,一定要注意 group key 的类型。
How It Order?
上面提到了,Object.groupBy 返回的是 object,而 object 的 key 是很难确保顺序的,参考: Object.keys(..)对象属性的顺序? (number first, order by create, symbol last)
const people = [ { name: 'derrick', age: 11 }, { name: 'derrick', age: 15 }, { name: '1148', age: 22 }, { name: '1148', age: 18 }, ]; console.log(Object.keys(Object.groupBy(people, person => person.name))); // ['1148', 'derrick'] console.log([...Map.groupBy(people, person => person.name).keys()]); // ['derrick', '1148']
Object.keys 的顺序是数字优先的 (哪怕是 string number 也同样优先...), 所以,如果想依据 array 原本的顺序那么请尽可能使用 Map.groupBy。
Multiple Group Key
C# LINQ GroupBy 支持 multiple group key
items.GroupBy(item => new { item.Name, item.Age }).ToList()
但 JS 没有这个功能,勉强要实现的话可以用 JSON.stringify,只是性能差又不优雅...
分享我以前写的 group by multiple key
function groupByMultipleKey<TItem, TKeys extends unknown[]>( items: TItem[], keySelector: (item: TItem, index: number) => [...TKeys], ): Map<[...TKeys], TItem[]> { const resultMap = new Map<[...TKeys], TItem[]>(); for (let index = 0; index < items.length; index++) { const item = items[index]; const itemKeys = keySelector(item, index); const existedKey = [...resultMap.keys()].find(resultKeys => resultKeys.every((resultKey, index) => isSameKey(resultKey, itemKeys[index])), ); if (existedKey !== undefined) { resultMap.get(existedKey)!.push(item); } else { resultMap.set(itemKeys, [item]); } } return resultMap; function isSameKey(key1: unknown, key2: unknown): boolean { if (key1 === key2) return true; if (Number.isNaN(key1) && Number.isNaN(key2)) return true; if (key1 instanceof Date && key2 instanceof Date) { return key1.getTime() === key2.getTime(); } return false; } }