前言
array group by 是一个很常见的功能. 但 JS 却没有 build-in 方法. 一直到 es2023 才有 group 和 groupToMap (目前没有任何游览器支持, 但已经有 polyfill 了).
这篇来聊一聊这个.
参考
Stack Overflow – Most efficient method to groupby on an array of objects (用 reduce 实现的 group by, 也是最 popular 的方案)
Setup
目前游览器不支持, 要 test 需要安装 core.js
yarn add core-js
然后 import, 然后 declare TypeScript
import 'core-js/actual/array/group'; import 'core-js/actual/array/group-to-map'; declare global { interface Array<T> { group<GroupKey extends string | symbol>( callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Record<GroupKey, T[]>; groupToMap<GroupKey>( callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Map<GroupKey, T[]>; } }
Array.group
group<GroupKey extends string | symbol>( callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Record<GroupKey, T[]>;
group 的用法很简单, 给一个 callbackfn, 返回一个 key. 它会把相同 key 的 item group 在一起.
最终返回一个对象, 对象的 key 就是 group by 的 key, value 则是相同 key 的所有 items.
例子: group by name
const items = [ { name: 'Derrick', age: 1 }, { name: 'Peter', age: 1 }, { name: 'Derrick', age: 2 }, { name: 'Peter', age: 2 }, ]; const result = items.group(item => item.name); console.log(JSON.stringify(result, null, 2));
结果
注意: key 只能是 string or symbol. 其它的都不可以, 包括 number, 如果 callbackfn 返回不是 string | symbol 会被强转成 string.
Array.groupToMap
groupToMap<GroupKey>( callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Map<GroupKey, T[]>;
它和 group 差不多. 只是 group key 不强制是 string | symbol. 可以返回任何类型.
因为 groupToMap 返回的结果不是 object, 而是 Map. 而我们知道 Map 的 key 可以是任何类型.
const items = [ { name: 'Derrick', age: 1 }, { name: 'Peter', age: 1 }, { name: 'Derrick', age: 2 }, { name: 'Peter', age: 2 }, ]; const result = items.groupToMap(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.
如果用 group 的话, 它会先把 Date 转成 string 然后放入对象的 key (利用对象 key unique 特性来 group, 可以理解为 key1 === key2 就 group 在一起)
如果是 groupToMap 则是放入 Map 的 Key. 这里和 group 有一个微小的区别, 它不会把 Date 强转成 string, 所以 key1 === key2 对比的是 Date object 而不是 Date string
通常, 我们的直觉会认为是, 相同的 date time value group 在一起, 而不是相同指针 group 在一起. 这样用 groupToMap 的结果就是错误的了.
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 = items.group(item => item.date as unknown as string); console.log(Object.keys(result1).length); // 1 const result2 = items.groupToMap(item => item.date); console.log([...result2.keys()].length); // 2
所以呢, 在使用 group 或 groupToMap 时, 一定要注意 group key 的类型哦.
How It Order?
上面提到了, Array.group 返回的是 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(people.group(person => person.name))); // ['1148', 'derrick'] console.log([...people.groupToMap(person => person.name).keys()]); // ['derrick', '1148']
Object.keys 的顺序是数字优先的 (哪怕是 string number 也同样优先...), 所以, 如果想依据 array 原本的顺序那么请尽可能使用 groupToMap
Multiple Group Key
C# LINQ GroupBy 支持 multiple group key
items.GroupBy(item => new { item.Name, item.Age }).ToList()
但 JS 没有这个功能. 勉强要实现的话可以用 JSON.stringify, 只是性能差又不优雅...
我以前写的 group by, 可以用 multiple group key.type SupportedGroupKey = string | number | boolean | Date | null | undefined; declare global { interface Array<T> { groupBy<GroupKey extends SupportedGroupKey | SupportedGroupKey[]>( callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Map<GroupKey, T[]>; } } // eslint-disable-next-line no-extend-native Object.defineProperty(Array.prototype, 'groupBy', { enumerable: false, value<T, GroupKey extends SupportedGroupKey | SupportedGroupKey[]>( this: T[], callbackfn: (this: T[], item: T, index: number, array: T[]) => GroupKey ): Map<GroupKey, T[]> { const resultMap = new Map<GroupKey, T[]>(); for (let index = 0; index < this.length; index++) { const value = this[index]; const groupKey = callbackfn.call(this, value, index, this); const existedKey = [...resultMap.keys()].find(mapKey => isSameKey(mapKey, groupKey)); if (existedKey !== undefined) { resultMap.get(existedKey)!.push(value); } else { resultMap.set(groupKey, [value]); } } return resultMap; function isSameKey(key1: GroupKey, key2: GroupKey): boolean { if (key1 instanceof Date && key2 instanceof Date) { return key1.getTime() === key2.getTime(); } if (Array.isArray(key1) && Array.isArray(key2)) { return key1.every((k1, index) => isSameKey(k1 as GroupKey, key2[index] as GroupKey)); } return key1 === key2 || (Number.isNaN(key1) && Number.isNaN(key2)); } }, }); const items = [ { name: 'Derrick', age: 1 }, { name: 'peter', age: 1 }, { name: 'Derrick', age: 2 }, { name: 'peter', age: 2 }, { name: 'Derrick', age: 2 }, { name: 'peter', age: 2 }, ]; const result1 = items.groupBy(item => item.name); const result2 = items.groupBy(item => [item.name, item.age]); console.log(result1); console.log(result2);