Pivot grid без сторонних библиотек: кэш, производительность и связанные гриды
- среда, 1 июля 2026 г. в 00:00:12
Расскажу, как и почему я в какой-то момент решил написать собственный pivot grid — без сторонних библиотек, на чистом JavaScript и DOM. И что из этого получилось: от первой версии с обычным GROUP BY до кэширования больших выборок и цепочки связанных гридов.
Я давно работаю с BI-инструментами, и мне всегда хотелось получить более удобную сводную таблицу. В готовых решениях вроде бы всё есть, но при реальной работе постоянно что-то мешает: где-то таблица ведёт себя не так, как хотелось бы, где-то не хватает нужного поведения, где-то на больших объёмах данных всё начинает заметно тормозить. В какой-то момент я решил попробовать сделать свой вариант.
Первую версию я сделал максимально прямолинейной. Пользователь выбирает измерения, на сервер уходит запрос с GROUP BY по выбранным измерениям, сервер возвращает результат, а на клиенте по этим данным строится таблица.
Сама идея простая. Сложность началась на интерфейсе.
Нужно было сделать сводную таблицу, в которой можно сворачивать и разворачивать и строки, и колонки. Со строками всё оказалось более-менее понятно. С колонками пришлось повозиться намного больше: там быстрее появляется сложная шапка, несколько уровней, объединённые ячейки, скрытие и раскрытие дочерних значений. Хотелось, чтобы таблица при этом оставалась читаемой, а не превращалась в набор технически правильных, но неудобных блоков.
После этого я добавил возможность отключать промежуточные итоги. Они нужны не всегда. Иногда они помогают, а иногда только перегружают таблицу и мешают смотреть на данные.

Первые версии кода я писал сам. Потом стал привлекать к работе языковую модель. Лучше всего с написанием кода справился Claude. Он неплохо помогает ускорить рутинную часть: набросать функцию, переписать кусок логики, предложить вариант реализации. Но ошибки у него тоже есть, и самые неприятные — архитектурные. Их не всегда видно сразу. Часть таких ошибок я поймал уже ближе к концу, когда казалось, что основная часть работы закончена.
Постепенно вокруг таблицы появился набор управляющих элементов: выбор измерений, выбор агрегирующей функции, сворачивание и разворачивание строк и колонок, выгрузка в CSV, увеличение и уменьшение видимого окна грида.
Последнее сначала появилось как вынужденная мера. Когда в колонках много измерений, места на экране быстро перестаёт хватать. Позже оказалось, что управление видимой областью пригодится и в других сценариях, особенно когда появился связанный грид.
Отдельно пришлось дорабатывать фильтры: как их применять, как показывать уже выбранные значения, как не терять контекст при работе с таблицей.

Следующим шагом я занялся производительностью.
Я заметил, что если выбрать N измерений в GROUP BY-запросе, то на основе этой выборки можно построить любой набор из N-A измерений, где 0 < A < N. То есть если уже есть данные по более детальной группировке, из них можно собрать более крупную группировку без нового запроса к серверу.
Так появилась идея кэша.
Пользователь выбирает набор измерений для кэширования. Сервер отдаёт данные по этому набору, а дальше таблица может перестраиваться на клиенте внутри уже загруженных измерений. Это заметно уменьшает количество обращений к серверу и ускоряет работу с таблицей.

С кэшем сразу появился вопрос агрегации.
Для sum, count, min и max всё относительно просто. Их можно пересчитать из более детальных данных без особых проблем. С avg, variance и stddev так не получится. Одного готового значения недостаточно, чтобы корректно собрать новую агрегацию на более верхнем уровне.
Поэтому пришлось хранить не только итоговое значение, но и вспомогательные данные: сумму значений, количество, сумму квадратов. При сворачивании в более крупную группировку итоговое значение пересчитывается уже на лету.
Дальше всплыла проблема хранения данных на клиенте.
JavaScript не предназначен для того, чтобы быть аналитическим движком. JSON тоже не самый компактный формат, если данных много. При больших объёмах становится важно не только то, как быстро всё считается, но и сколько места это занимает в памяти браузера.
Я реализовал колончатое хранение данных и словарное кодирование измерений на клиенте. Вместо того чтобы хранить каждую строку как объект с повторяющимися строковыми значениями, данные хранятся по колонкам, а значения измерений заменяются кодами. Памяти стало уходить заметно меньше.
На серверной стороне тоже пришлось внести изменения. При очень больших ответах браузер мог просто падать с ошибкой. Поэтому я добавил GZIP и пакетную передачу данных от сервера к клиенту. Данные приходят не одним большим ответом, а частями.
Управление кэшем я вынес в интерфейс. Добавил плашку, где можно выбрать измерения для кэширования, и индикатор, который показывает, сколько строк ещё помещается в кэш. Максимальный объём кэша задаётся в конфигурационном файле на сервере.
После загрузки данных в кэш таблица стала работать заметно удобнее. Переключения внутри выбранного набора измерений происходят быстрее, часть операций вообще не требует обращения к серверу.
По моим проверкам, без заметных тормозов всё работало до 500 000 строк в кэше при 13 измерениях и двух мерах. До миллиона строк тормоза уже появлялись, но работать ещё было можно. При больших объёмах дольше всего становилась именно загрузка.
Анализ производительности показал, что основную задержку давал не клиент, а Postgres, который я использовал как хранилище данных. Я взял его как самый простой и доступный вариант.
При этом серверную часть я сделал через провайдеры. Клиент получает данные в JSON, а конкретное хранилище спрятано за отдельным провайдером. Поэтому Postgres здесь не жёсткая привязка: можно добавить свой провайдер и подключить другое хранилище данных.

С более быстрыми хранилищами я отдельно не экспериментировал. Думаю, с ними можно было бы комфортнее работать на больших объёмах. Более компактные форматы передачи данных тоже сознательно не рассматривал: это добавило бы зависимости и усложнило бы решение. Мне хотелось использовать то, что максимально распространено и не требует дополнительной инфраструктуры.
Даже с оптимизациями один грид на очень большом объёме данных всё равно остаётся одним гридом. Поэтому я стал думать не только о том, как ускорить текущую таблицу, но и о том, как организовать работу с данными иначе.
Так появилась цепочка связанных гридов.
Смысл в том, что пользователь сначала работает с одним гридом, постепенно сужает данные, открывает нужные измерения и в какой-то момент может перейти в следующий грид. Переход происходит по клику на значение в ячейке.
Во второй грид уже передаются отфильтрованные данные. Объём там меньше, поэтому можно использовать более глубокие измерения и не пытаться держать всю детализацию в одной таблице. Второй грид не загружается заранее, он появляется только после выбора нужной ячейки в первом гриде.
После реализации этой механики даже на миллионе строк работа оставалась вполне комфортной. Первый грид показывает общий срез, а второй открывается уже по выбранному контексту и работает с меньшим набором данных. За счёт этого можно уходить в детализацию глубже, не пытаясь заранее загрузить всё в одну таблицу.

Здесь возникла другая задача — как разместить два грида в одном окне браузера. Снова пригодилась возможность увеличивать и уменьшать видимую область. Я думал вынести связанный грид в отдельное окно, но пока не стал этого делать. Для демонстрации и обычной работы вариант с двумя гридами в одном окне оказался понятнее.

Отдельно я рассматривал вариант с полностью ленивой загрузкой, когда данные подгружаются только в момент раскрытия конкретного значения в измерении. Пока я этот вариант отложил.
Там появляются свои сложности: ожидание загрузки при раскрытии, перерисовка таблицы на лету, блокировка таблицы в момент раскрытия, сложности с CSV. Плюс при таком подходе заранее неизвестно общее количество строк, а это создаёт проблемы для виртуального скролла.
По сути, полностью ленивая загрузка — это уже другая сводная таблица, с другой архитектурой и другим набором проблем.
Для удобства я добавил обработку клика по ячейке. При клике можно посмотреть результат запроса по фильтрам из этой ячейки во всплывающем окне или передать значения этих фильтров в новое окно браузера. Также можно перехватить событие клика и навесить на него свою логику.
Это оказалось полезно не только для связанных гридов. В реальной работе часто хочется связать таблицу с внешним действием: открыть детализацию, перейти к карточке объекта, построить другой отчёт, передать фильтры дальше.
Ещё добавил возможность отключать панель работы с кэшем и реакцию на клик по ячейке. Иногда нужен минимальный вариант сводной таблицы без дополнительных элементов.
Чтобы не заполнять конфигурационный файл руками, сделал страницу конфигурации. Через неё можно настроить таблицу быстрее и с меньшей вероятностью ошибиться.
Потом были уже более технические задачи: превратить всё это в пакет, сделать обёртку, изолировать области видимости, привести структуру к виду, в котором компонентом можно пользоваться отдельно.

Вообще, эта история началась не сейчас. Что-то похожее я пытался сделать ещё около десяти лет назад, но тогда не смог довести до нормального состояния. Сейчас сильно помог Claude: без него я бы, скорее всего, не продвинулся так далеко в одиночку.
Я посмотрел на нынешние варианты pivotgrid и понял, что ощущение осталось тем же: готовые решения есть, но мне всё равно хочется немного другого поведения. Так я снова вернулся к этой задаче — уже с целью довести её до рабочего пакета.
В итоге получился pivot grid, который можно подключить как npm-пакет и настроить под свои данные.
Код опубликован на GitHub, пакет доступен на npm как pivotgrid-js. В репозитории указаны условия использования. Также есть живое демо: оно работает на отдельном клиентском провайдере, который читает данные из файла вместо обращения к серверу. Это удобно для показа возможностей, но такую схему не стоит воспринимать как пример боевой архитектуры.
Больше всего в решении дали выигрыш кэш, колончатое хранение, словарное кодирование и цепочка связанных гридов. Кэш ускоряет работу внутри выбранного набора измерений. Колончатое хранение и словарное кодирование экономят память. Связанные гриды позволяют уходить в детализацию без попытки поместить весь объём данных и все уровни анализа в одну таблицу.
Для меня было важно довести проект не до набора кода в папке, а до отдельного компонента, который можно подключить, проверить на своих данных и при необходимости доработать под задачу.