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

 

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