12k
All articles

Группировка массивов в JavaScript с помощью Object.groupBy

Object.groupBy в JavaScript группирует массивы по ключу, сравнивает reduce и Map.groupBy, и объясняет приведение к строке и объект без прототипа.

OpenReplay Team
OpenReplay Team
Группировка массивов в JavaScript с помощью Object.groupBy

Object.groupBy(items, callback) группирует массив за один вызов: он выполняет колбэк по одному разу для каждого элемента, использует возвращённое значение как имя группы и возвращает объект, содержащий по одному массиву подходящих элементов под каждым именем.

Во множестве кодовых баз до сих пор живёт самописный reduce для этой задачи — с одними и теми же несколькими строками шаблонного кода аккумулятора, скопированными из файла в файл, — или сохранённый импорт Lodash ради единственного вызова groupBy. Ни то, ни другое не является ошибкой, и оба варианта по-прежнему работают; но с появлением нативного метода ни один из них больше не нужен.

В этой статье показан нативный вызов на конкретной задаче (заказы по статусам), проведено сравнение с reduce, который он заменяет, объяснено, когда вместо него уместнее Map.groupBy, и разобраны два поведения, о которые спотыкаются в продакшене: ключи, незаметно превращающиеся в строки, и результирующий объект, у которого нет hasOwnProperty.

Ключевые выводы

  • Object.groupBy вызывает колбэк с двумя аргументами, (element, index), и использует возвращаемое значение как ключ группы.
  • Замена аккумулятора в reduce на Object.groupBy — это изменение ради читаемости, а не производительности; нет оснований ожидать, что он будет работать быстрее.
  • Используйте Map.groupBy, когда ключ группировки не является строкой: объект, Date или число, которое нужно сохранить именно числом.
  • Группировка по булеву значению или числу с помощью Object.groupBy даёт строковые ключи "true" и "40", поскольку каждый ключ приводится к ключу свойства.
  • Объект, возвращаемый Object.groupBy, имеет прототип null, поэтому result.hasOwnProperty(...) выбрасывает TypeError; используйте Object.hasOwn или скопируйте результат через spread.

Группировка заказов по статусу в три строки

Для массива объектов-заказов Object.groupBy создаёт объект с ключами-статусами одним выражением — без аккумулятора и без проверки на существование.

const orders = [
  { id: 1, status: "shipped",  total: 40 },
  { id: 2, status: "pending",  total: 15 },
  { id: 3, status: "shipped",  total: 60 },
  { id: 4, status: "refunded", total: 22 },
];

const byStatus = Object.groupBy(orders, (order) => order.status);

console.log(Object.keys(byStatus));   // ["shipped", "pending", "refunded"]
console.log(byStatus.shipped.length); // 2

Справка по Object.groupBy() на MDN описывает контракт: первый аргумент — любой итерируемый объект, не только массив, а результат содержит по одному свойству на каждый уникальный ключ. Группы располагаются в порядке появления их первого элемента. Элементы внутри каждой группы — это исходные объекты, а не копии, поэтому мутация byStatus.shipped[0] изменяет и orders[0].

Как работает колбэк Object.groupBy?

Колбэк получает два аргумента — текущий элемент и его индекс, — и то, что он возвращает, становится ключом группы для этого элемента. Определение Object.groupBy в ECMA-262 специфицирует ровно эти два аргумента; третьего аргумента с «полным массивом», как в map или filter, здесь нет.

Поскольку ключ вычисляется, колбэк не ограничен чтением поля. Работает любое выражение, дающее строку, включая сравнение или корзину, выведенную из индекса:

const bySize = Object.groupBy(orders, (order) => (order.total >= 50 ? "large" : "small"));
// { small: [order 1, order 2, order 4], large: [order 3] }

const byHalf = Object.groupBy(orders, (_, index) => (index < 2 ? "first" : "second"));
// { first: [order 1, order 2], second: [order 3, order 4] }

Каждый элемент попадает ровно в одну группу. Если два элемента дают одинаковый ключ, они разделяют один массив в порядке добавления.

Группировка в JavaScript: reduce против Object.groupBy

Замена аккумулятора в reduce на Object.groupBy убирает начальный объект, проверку существования для каждого элемента, создание массива, push и return; остаётся единственная строка, определяющая, к какой группе относится элемент.

Вот версия, которая уже есть в большинстве кодовых баз, записанная настолько компактно, насколько это позволяет логическое присваивание с нулевым слиянием:

const byStatusReduce = orders.reduce((acc, order) => {
  acc[order.status] ??= [];
  acc[order.status].push(order);
  return acc;
}, {});

И тот же результат с нативным методом:

const byStatus = Object.groupBy(orders, (order) => order.status);

Оба варианта дают эквивалентную группировку. Разница в том, сколько всего читателю приходится держать в голове. В форме с reduce намерение сгруппировать размазано по начальному значению аккумулятора, условному созданию массива, мутации и возвращаемому значению — и любой из этих четырёх шагов может быть незаметно неверным. В нативной форме проверять остаётся только функцию ключа.

Object.groupBy — это изменение ради читаемости, а не производительности. Оба подхода проходят по входным данным один раз и создают по одному массиву на группу, и нет оснований ожидать, что нативный вызов окажется быстрее хорошо написанного reduce. Выбирайте его потому, что он убирает шаблонный код, а не из-за бенчмарков.

Когда следует использовать Map.groupBy?

Используйте Map.groupBy, когда ключ группировки не является строкой: объект, Date или число, которое нужно сохранить числом. Map.groupBy() принимает такой же колбэк с двумя аргументами и отличается только типом возвращаемого значения — это Map, ключами которого являются в точности те значения, которые вернул колбэк.

const byTotal = Map.groupBy(orders, (order) => order.total);

console.log([...byTotal.keys()]);           // [40, 15, 60, 22]
console.log(typeof [...byTotal.keys()][0]); // "number"
console.log(byTotal.get(40).length);        // 1

Ключи возвращаются числами, в порядке вставки, и читаются через .get(). Собственный пример MDN на странице Map.groupBy группирует по идентичности объектов — случай, с которым объектный литерал не справляется в принципе: два разных объекта с идентичным содержимым остаются разными ключами Map.

Object.groupByMap.groupBy
Тип ключаПриводится к строке или символуЛюбое значение, сохраняется как есть
Тип результатаОбъект с прототипом nullMap
Чтение группыresult.shippedresult.get(key)
Порядок перебора ключейПорядок вставки, кроме целочисленных ключей — они сортируются по возрастаниюПорядок вставки

Почему Object.groupBy превращает числовые ключи в строки?

Группировка по булеву значению или числу с помощью Object.groupBy даёт строковые ключи "true" и "40", а не исходные true и 40; Map.groupBy сохраняет исходные значения в качестве ключей Map. Всё, что возвращает колбэк, должно в итоге стать ключом свойства, поэтому любое значение, которое ещё не является строкой или символом, по пути преобразуется в строку. Это обычное поведение объектов, но оно всё равно застаёт врасплох тех, кто ожидает результат с булевыми ключами.

const byPaid = Object.groupBy(orders, (order) => order.status === "shipped");

console.log(Object.keys(byPaid));             // ["true", "false"]
console.log(typeof Object.keys(byPaid)[0]);   // "string"
console.log(byPaid[true] === byPaid["true"]); // true (поиск тоже приводит к строке)

const byTotalObj = Object.groupBy(orders, (order) => order.total);
console.log(Object.keys(byTotalObj));         // ["15", "22", "40", "60"]

В числовом случае происходят две вещи. Суммы стали строками, и вернулись они отсортированными по возрастанию, а не в порядке вставки, потому что целочисленные ключи свойств перечисляются в порядке возрастания числового значения раньше остальных строковых ключей. Код, который обходит результат в расчёте на исходную последовательность, отрисует группы в неверном порядке, не выбросив при этом никакой ошибки.

Записи сессий с багами группировки часто показывают ровно такую картину: заголовок категории, где написано true вместо «Shipped», или корзины, отсортированные тогда, когда данные отсортированы не были. Консоль чистая, форма данных в логах выглядит корректно, потому что { true: [...] } печатается одинаково независимо от того, булев ключ или строковый, и только сопоставление отрисованного UI с путём выполнения кода делает приведение типов очевидным.

Почему hasOwnProperty выбрасывает ошибку на результате Object.groupBy?

Объект, возвращаемый Object.groupBy, имеет прототип null, поэтому result.hasOwnProperty("shipped") выбрасывает TypeError; используйте Object.hasOwn(result, "shipped") или скопируйте результат через spread-синтаксис, если нижележащий код ожидает обычный объект. MDN документирует возвращаемое значение как объект с прототипом null, а это значит, что ничто из Object.prototype через него недоступно: ни hasOwnProperty, ни toString, ни valueOf.

const byStatus = Object.groupBy(orders, (o) => o.status);

byStatus.hasOwnProperty("shipped");
// TypeError: byStatus.hasOwnProperty is not a function

Object.hasOwn(byStatus, "shipped"); // true
"shipped" in byStatus;              // true
Object.keys(byStatus);              // ["shipped", "pending", "refunded"]
JSON.stringify(byStatus);           // работает как обычно

const plain = { ...byStatus };      // обычный объект с Object.prototype
plain.hasOwnProperty("shipped");    // true

MDN указывает на Object.hasOwn как на современную замену hasOwnProperty, и он имеет статус Baseline Widely available с марта 2022 года, так что его можно использовать напрямую. Object.keys, Object.entries, оператор in, JSON.stringify и spread — всё это работает с результатом, у которого прототип null, поскольку ничто из перечисленного не зависит от цепочки прототипов. Сбой проявляется только тогда, когда какой-нибудь хелпер — часто глубоко внутри библиотеки-утилиты или шаблонизатора — вызывает метод на самом объекте. Типичный симптом — группа, которая молча никогда не отрисовывается, или TypeError, выброшенный изнутри цикла рендеринга.

Какие браузеры поддерживают Object.groupBy и Map.groupBy?

Object.groupBy и Map.groupBy имеют одинаковый уровень поддержки: оба отмечены на MDN как Baseline Widely available и доступны во всех браузерах с марта 2024 года, так что ни одному из них не нужен полифилл для актуальных целевых браузеров. Выбор между ними сводится к ключу: если имя группы естественным образом является строкой (статус, категория, название команды), Object.groupBy даёт вам объект обычного вида, к которому можно обращаться через точечную нотацию. Если ключ — объект, Date, число, с которым вы будете производить арифметические операции, или булево значение, которое вы хотите сравнивать как булево, Map.groupBy сохранит его в неизменном виде и позволит избежать обеих описанных выше ловушек.

Прощаемся с аккумулятором

reduce со строкой ??= [] может превратиться в однострочный вызов Object.groupBy всякий раз, когда ключ группировки — строка и никакой нижележащий код не вызывает hasOwnProperty на результате. Когда ключ имеет любой другой тип, берите Map.groupBy и читайте группы через .get(). В любом случае единственный код, который остаётся тестировать, — это логика, определяющая принадлежность к группе.

Часто задаваемые вопросы

Работает ли Object.groupBy в TypeScript и какой тип он возвращает?

Да. В TypeScript 5.4 добавлены объявления типов для Object.groupBy и Map.groupBy, доступные, когда target или lib в tsconfig включает es2024 или esnext; при более старых настройках lib сообщается, что groupBy не существует у ObjectConstructor. Object.groupBy типизирован как Partial Record, поэтому каждая группа потенциально undefined и требует проверки перед обращением по индексу. Map.groupBy типизирован как Map из типа ключа в массив элементов.

Что произойдёт, если колбэк Object.groupBy вернёт undefined или null?

Элемент попадёт в группу с ключом-строкой 'undefined' или 'null', поскольку Object.groupBy преобразует любой результат колбэка в ключ свойства. Ничего не пропускается и никакой ошибки не выбрасывается, поэтому отсутствующее поле молча порождает лишнюю группу. Map.groupBy сохраняет фактическое значение undefined или null в качестве ключа Map. Чтобы исключить такие элементы, сначала отфильтруйте массив или возвращайте запасной ключ, например 'unknown'.

В чём разница между Object.groupBy и groupBy из Lodash?

Lodash groupBy возвращает обычный объект, наследующий от Object.prototype, поэтому hasOwnProperty на нём работает; Object.groupBy возвращает объект с прототипом null. Lodash принимает сокращённую запись с именем свойства, например groupBy(orders, 'status'), и вызывает функцию-итератор с одним аргументом — значением, тогда как Object.groupBy требует функцию и передаёт элемент и его индекс. Lodash также принимает на вход обычные объекты; Object.groupBy принимает любой итерируемый объект. Оба приводят ключи к строкам.

Как сгруппировать по нескольким полям с помощью Object.groupBy?

Возвращайте из колбэка одну составную строку, например объединяя статус и корзину по размеру через разделитель, чтобы получались ключи вида 'shipped:large'. У Object.groupBy нет режима с несколькими ключами; каждый элемент получает ровно один ключ свойства. Если поля нужны по отдельности, вложите вызовы: сначала сгруппируйте по статусу, затем запустите Object.groupBy на массиве каждой группы для второго поля — получится двухуровневая структура, читаемая как result.shipped.large.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.