Группировка массивов в JavaScript с помощью Object.groupBy
Object.groupBy в JavaScript группирует массивы по ключу, сравнивает reduce и Map.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.groupBy | Map.groupBy | |
|---|---|---|
| Тип ключа | Приводится к строке или символу | Любое значение, сохраняется как есть |
| Тип результата | Объект с прототипом null | Map |
| Чтение группы | result.shipped | result.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.
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