JavaScript – Group / GroupToMap

前言

array group by 是一個很常見的功能. 但 JS 卻沒有 build-in 方法. 一直到 es2023 纔有 group 和 groupToMap (目前沒有任何遊覽器支持, 但已經有 polyfill 了).

這篇來聊一聊這個.

 

參考

ECMAScript 2023將新增的九個數組方法

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);
View Code

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章