Привет! Я Дмитрий Дин, тимлид в диджитал-продакшне Далее. Сегодня расскажу, как мы разработали свою BI-систему с гибкими дашбордами и реактивными фильтрами — и для этого я собрал библиотеку ReGraph.

Решение полезно фронтенд-разработчикам, тимлидам, архитекторам — всем, кто работает с динамическими интерфейсами, визуальными конструкторами и кастомной реактивностью.

Моей команде поставили амбициозную задачу — разработать BI-систему для аналитической платформы. С помощью платформы специалисты обрабатывают информацию и статистические данные о рекламных кампаниях. 

Мы должны были все свести в единое целое — удобную BI-систему, в которой можно делать отчетность и дашборды для графического представления информации. 

Пример одного из самых известных решений — Power BI от Microsoft. Основной интерфейс —  это большая панель с множеством графиков и визуализаций. Такие отчеты можно настраивать вручную: размещать блоки, перетаскивать, группировать и конфигурировать.

Есть и open source аналог — Superset. Именно его использовали на платформе до кастома. Но Superset хорош для базовых BI-сценариев, а в нашем случае он упирался в ограничения:

  • недостаточность встроенных опций;

  • большие проблемы при обновлении версий без обратной совместимости;

  • сложность кастомизации и интеграции собственных виджетов; 

  • плохая поддержка цветовых тем;

  • тяжелый UI для работы менеджеров.

Так мы пришли к созданию собственной BI-системы.

На первый взгляд все это выглядело довольно сложно. И первая реакция на задачу была: «Это же безумие — пилить свою BI-систему!» Но мы рискнули.

Что включает разработка BI-системы

  1. Интерактивный холст. Нам нужно было создать интерфейс, на котором размещаются виджеты. Это не просто верстка, а полноценная интерактивная поверхность с позиционированием, возможностью перемещать и настраивать компоненты.

  2. Запросы к данным. Мы не говорим про простой SQL-запрос к базе. Требовались более сложные выборки с агрегатами, фильтрами, суммами и логикой.

  3. Конфигурация виджетов. У каждого виджета есть своя позиция, размеры, настройки. Виджеты должны «знать», какие данные им нужны, и как эти данные обрабатывать.

  4. Реализация самих виджетов. По сути — выбор и подключение библиотеки для построения графиков и таблиц. Здесь важно было соблюсти баланс между гибкостью и производительностью.

  5. Сохранение и восстановление дашбордов. Если пользователь собрал дашборд, его нужно сохранить, а потом — восстановить и отобразить в нужном виде.

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

Стек для создания BI-системы

У нас уже есть опыт с похожими задачами, поэтому к моменту старта был определенный стек, на который мы могли опереться.

Во главе всего — Svelte. Мы активно его используем в наших проектах. Он предоставляет великолепный DX, что положительно сказывается как на продуктивности, так и на удовлетворенности разработчиков.

Для построения графиков — eCharts. Он хорошо себя показал: гибкий, быстрый, с большим количеством встроенных типов визуализации.

Для коммуникации с backend — мой собственный фреймворк «чёрт» (Chord). И да, я в курсе, что по-английски правильно говорить «корд», но как автор оставляю за собой право произносить «чёрт» — просто потому, что так забавнее 😜

В качестве ORM взяли Prisma. А еще нашли полезную библиотеку — Svelte Flow. Она помогает создавать интерактивные холсты: размещать и позиционировать элементы, реализовывать перетаскивание — прям как в Miro!

Но один вопрос остался открытым: как решать задачу с динамически связанными элементами? Готового ответа у нас не было. Пришлось копать глубже — в сторону реактивности.

Подходы к реактивности — смотрим Redux, RxJS и Effector

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

Существует два основных типа реактивности:

Pull-реактивность — когда подписчик сам запрашивает актуальные данные, то есть вытягивает их на себя.

Push-реактивность — когда система отправляет новые значения подписчикам, как только они появляются.

Основной паттерн, на котором все это основано, — Observer. Пример: к нам поступает событие, оно уходит в паблишер, и тот рассылает уведомления подписчикам. Каждый подписчик выполняет свою задачу: кто-то обновляет интерфейс, кто-то делает запрос на бэкенд, кто-то что-то рассчитывает.

В Redux у нас есть:

  • начальное состояние (initial state), которое мы передаем в store;

  • экшены, с помощью которых ��ы меняем состояние;

  • подписки, которые следят за обновлениями.

Раньше API Redux выглядело довольно громоздко — много switch/case внутри редьюсеров. Сейчас оно стало чуть чище, но по сути остается тем же.

Минусы Redux — это моностор без производных выражений и встроенных эффектов. API довольно примитивный и не слишком удобный.

RxJS — это реализация push-реактивности. Мы работаем с потоками данных. Есть Observable — источник, и есть Observer — подписчик, который реализует методы next, error и complete.

Подписка оформляется явно: мы подписываем Observer на Observable и начинаем получать данные.

RxJS позволяет объединять потоки: есть возможность собрать несколько Observables в один, и дальше вычислять производные значения. Это мощно, но требует определенного входа — порог чуть выше.

Effector — еще один интересный инструмент. В целом, он реализует похожие концепции: у нас есть сторы, ивенты, подписки.

Можно вызывать события, которые меняют стор, можно подписаться на изменения с помощью watch, можно запускать эффекты (например, API-вызовы), можно создавать derived-состояния.

Но это не сигналы

Слегка затронем тему сигналов, потому что все перечисленное выше — не совсем сигналы в привычном понимании. Когда говорят «сигналы», обычно имеют в виду минимальный, унифицированный API, который включает:

  • стейты,

  • производные стейты (derived),

  • эффекты.

Большинство современных фреймворков, которые мы знаем, уже так или иначе адаптируются к сигналам, но не полностью. Также существует пропозал, который предлагает добавить сигналы в натив веба.

Реактивность в Svelte

В Svelte 4 есть сторы — это writable, readable, derived и так далее. Они работают достаточно просто: чтобы изменить значение, мы вызываем методы set или update. После этого автоматически срабатывают подписки.

Здесь вы можете видеть два метода, которые реализуют каунтер простой и изменяют Store.

  const counter = writable(0);
  function increment() {
    counter.update(value => value + 1);
  }
  function decrement() {
    counter.update(value => value - 1)
  }

Также мы можем создать, как и в других библиотеках, Derived-состояние. Мы аккумулируем два Store в один и пересчитываем каждый раз, когда любое значение из двух Store изменяется.

 const combinedState = derived(
    [counter, doubled],
    ([$counter, $doubled]) => $counter + $doubled
  );
  combinedState.subscribe((value) =>
    console.log("Update combinedState:", value)
  );

В Svelte 5 появились руны (runes). Это новое, более чистое и нативное API для работы с реактивностью.

export class Counter {
    count = $state(0);
    doubled = $derived(this.count * 2);
    increment() {
      this.count++;
    }
    decrement() {
      this.count--;
    }
  }


Здесь мы используем обычный JS — никаких $, subscribe, set. Все значения обновляются автоматически, при этом API остается декларативным и понятным.

Это уже больше похоже на сигналы — такие же state/derived конструкции, только более интегрированные в фреймворк. И, что важно, они не завязаны так сильно на компиляции, как reactive declarations в Svelte 3 и 4, и их можно порождать в фабриках.

Проблема — динамические состояния

После того как мы разобрали разные подходы, возникает закономерный ряд вопросов — Все библиотеки умеют работать с состояниями, но…

  • Что если нам нужно создавать состояния динамически, прямо в рантайме?

  • Что если мы не можем заранее захардкодить зависимости между компонентами?

  • Что если нам нужно построить реактивную систему на лету, а не через статические импорты и декларации?

Эти вопросы особенно актуальны для BI-систем, где пользователь может в любой момент добавить фильтр, связать его с конкретным графиком или таблицей, а потом сохранить все это и восстановить позже.

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

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

Решение — кастомная ReGraph

Чтобы закрыть задачу с динамическими связями, я разработал собственную реактивную систему поверх Svelte 4 — ReGraph. По сути, это реактивный Graph, где:

  • Graph — это синглтон-объект, к которому можно в любой момент обратиться и получить любую ноду;

  • ноды — это реактивные элементы, каждый из которых содержит три состояния: input, computed и output;

  • ребра — связи между нодами, которые позволяют передавать данные и подписки.

Что дает ReGraph

В отличие от готовых BI-платформ вроде Superset или классических стейт-менеджеров, наша система на ReGraph не ограничена заранее заданной структурой. Все строится динамически: фильтры, виджеты, связи между ними.

  • Больше гибкости. Любой элемент можно добавить, связать или удалить на лету.

  • Меньше кода. Вместо сотен строчек ручной логики — несколько декларативных вызовов, описывающих граф зависимостей.

  • Выше производительность. Нет тяжелого слоя абстракции: Graph реагирует мгновенно, даже при десятках связанных компонентов.

  • Легко масштабировать. Новые Nodes и Edges добавляются без изменения архитектуры — граф сам подстраивается.

  • Удобно сохранять и переносить. Весь дашборд сериализуется в JSON, который можно восстановить в любом окружении.

  • Польза для бизнеса — быстрое создание кастомных дашбордов. 

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

Упрощенный код реализации конструктора для каждой ноды:

  // Node.constructor()
  this.input = writable(value);
  this.deps = writable(deps);
  this.computed = derived(this.input, (v) => v);
  this.output = writable();
  this.computedUnsubscriber = this.computed.subscribe((v) => {
      this.output.set(v);
  });

Здесь у нас Singleton Graph. Мы инициализируем его и передаем сразу в Svelte Context, аналогично React Context. Это позволяет в любом компоненте получить доступ к текущему Graph и обратиться к нужной ноде. Помимо этого, есть другие CRUD методы для работы с нодами.

export class ReGraph<T extends object> {
  _nodes: TConnections | Writable<Connections>
  constructor(nodes?: T) {
    this._nodes = nodes ?? ({}) as T
    this.connections = writable({})
    setContext('ReGraph', this)
  }
 // add/delete/nodeById и другие
}

Первый тест: кастомный отчет

Первым полигоном для ReGraph стал кастомный отчет — один из модулей нашей платформы. Интерфейс по функционалу похож на Яндекс.Метрику. 

Получился статичный отчет: все компоненты были прибиты гвоздями, никакой динамики, только фиксированная верстка и фиксированные зависимости. 

Тем не менее, даже в этих условиях нам удалось протестировать ключевые принципы:

  • вместо ручной подписки компонентов друг на друга мы связали их через Graph;

  • применили реактивность ReGraph внутри всех связей;

  • убедились, что даже в жесткой структуре Graph работает корректно.

На иллюстрации можно увидеть, как это выглядело в терминах ReGraph: каждый компонент — отдельная нода с тремя состояниями. Все ноды связаны друг с другом через edges (подписки), и при изменении одной данные автоматически обновлялись во всех зависимых.

Выводы после теста

  • Графовая модель отлично легла на Кастомный отчет.

  • Виджеты удобно инкапсулируются в нодах.

  • Данные можно доставать из Graph в любой момент.

Но мы не проверили две вещи:

  • динамические подписки во время рантайма;

  • сохранение/восстановление Graph в JSON — все было статично и жестко зашито.

Теперь переходим к рабочей версии BI-системы, где мы уже полностью раскрыли потенциал ReGraph и задействовали все фичи.

Реализация BI-системы

После успешного теста на кастомном отчете мы приступили к усиленной разработке BI-системы. На этот раз уже с динамикой, конструктором и полноценной интеграцией ReGraph.

Все устроено так:

  1. Пользователь создает дашборд.

  2. Добавляет на холст виджеты — таблицы, графики, фильтры.

  3. Каждому виджету соответствует своя нода в Graph.

  4. В зависимости от связей между виджетами ReGraph выстраивает цепочки реактивности.

Реализация BI-системы
Реализация BI-системы

BI-дашборд, который в итоге мы собрали содержит: 

  • комбинированный график, 

  • несколько factoid-виджетов,

  • таблицу,

  • три фильтра — два глобальных и один локальный.

В абстрактном виде на уровне нод Graph выглядит так:

Видно, что у нас есть композиция фильтров из календаря, есть фильтр вкладки, который был сверху в углу — фиолетовый. Дальше он перетекает в локальный фильтр, который применяется только к таблице. И после этого значения подставляются таблицу. Запросы отправляются через Chord в CubeJS. 

Другие виджеты работают аналогичным образом, за тем исключением, что у них нет локальных фильтров, как у таблицы. То есть они работают уже сразу напрямую с двумя фильтрами без посредников. И таким образом у нас данные перетекают слева направо, и все реактивно обновляется.

Сохранение и восстановление виджетов

Теперь разберем немного то, как у нас работает сохранение виджетов, так как я говорил, что мы еще не тестили эту фичу. Вот здесь уже есть код, который как раз это реализует.

for (const [id, node] of Object.entries(this.graph._nodes)) {
  const data = get(node.input); // Достаем текущее input состояние
  const widget = saveWidget(data); // Сохраняем input состояние
}

Все просто. Пробегаемся по Graph, итерируемся по всем нодам, из каждой ноды извлекаем ее input-состояние и собираем это в удобную для нас JSON-структуру.

Такой JSON можно хранить в базе или на клиенте, и он будет отражать текущую структуру дашборда — со всеми связями и параметрами.

Чтобы восстановить дашборд, читаем сохраненный JSON. Создаем новые ноды на его основе и передаем input-данные из сохраненного состояния — Graph сам восстанавливает связи и подписки.

for (const widget of savedWidgets) {
  const node = new Node({ id: widget.id, value: widget, graph: this.graph });
  ...
}

Это решение полностью отвязывает реактивность от кода: все поведение можно сохранить, отложить и поднять позже, без участия разработчика. Причем все связи останутся на месте — никаких addEventListener, никаких ручных subscribe.

Что смогли достичь

За два месяца наша команда собрала рабочую BI-систему поверх ReGraph. В цифрах и фактах это выглядит так:

  • Скорость разработки — новые виджеты и фильтры подключаются за часы, а не за недели.

  • Кодовая база — вместо сотен строк ручной логики зависимости описываются в 2–3 декларативных вызовах.

  • Реактивность — любое изменение в фильтре прокатывается по графу автоматически, без ручных subscribe и «забытых» обновлений.

  • Масштабируемость — рост числа виджетов не увеличивает сложность кода: Graph остается единым, добавляются только новые Nodes и Edges.

  • Портируемость — все состояние хранится в JSON, что позволяет легко сохранять и восстанавливать дашборды, переносить их между окружениями и тестировать конфигурации.

Перфоманс — за счет точечных обновлений по месту, там где это необходимо, отклик интерфейса остается «живым» даже при множестве связей и фильтров.

Где еще можно использовать ReGraph

Хотя изначально ReGraph создавалась под задачи BI, ее архитектура хорошо ложится и на другие кейсы. Есть несколько направлений, где можно применить систему.

1. Конструкторы ботов

Если вы строите визуальный интерфейс для проектирования диалогов — ReGraph может выступать как движок, описывающий логику переходов между состояниями. Каждое состояние — нода, связи между ними — Edges.

2. Динамические опросники

Формы, которые подстраиваются под ответы пользователя на лету. Например: если пользователь выбрал «юридическое лицо», появляются поля про ИНН и КПП — это можно выразить через динамические ноды и связи между ними.

3. Web Audio API

Браузерное API для работы со звуком тоже построено на графах: фильтры, осцилляторы, компрессоры. ReGraph можно адаптировать для построения сложных звуковых цепочек, где каждая нода — это аудио-нода с параметрами и входами/выходами.

Конечно, этим список не ограничивается. Вы можете использовать ReGraph для своих специфических задач.

Рецепт: как приготовить ReGraph самому

Если вы захотите реализовать ReGraph самостоятельно, то ниже — минимальные требования к фреймворку или библиотеке, на которой это можно сделать.

1. Поддержка динамических состояний

У вас должна быть возможность создавать состояния (сторы, сигналы и т.п.) не в compile-time, а в рантайме. Это исключает, например, реактивность в Svelte через let и $: — она статична. Подойдут store-based или signal-based системы.

2. Derived-состояния

Вы должны уметь объединять несколько состояний в производное, которое автоматически обновляется при изменении любого из источников. Это нужно для compute в ноде.

3. Отсутствие иерархии состояний

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

4. Поддержка эффектов

Необязательный, но желательный пункт. Эффекты (реакции на изменение состояний) сильно упрощают код и позволяют реализовывать подписки, обновления интерфейса, запросы к API и прочие побочные действия.

Все должно быть сведено воедино: Graph как синглтон, набор нод и ребра — связи между ними. Именно такая архитектура делает ReGraph универсальной и переносимой.

Демо

Это простое приложение на ReGraph — сумматор: вы создаете ноды, связываете их — и они начинают автоматически суммировать входные значения. Все это работает в браузере и в реальном времени.

А здесь находится репозиторий с демо-реализацией ReGraph на Svelte 5. Это не полноценная библиотека, скорее MVP: код, который можно пощупать, запустить у себя и адаптировать под свои задачи.

Я надеюсь, что теперь вы взглянете иначе на реактивность. Состояния — это не обязательно switch/case, экшены и subscribe.

Иногда вместо того чтобы городить реактивную логику вручную, стоит посмотреть на задачу как на граф, и тогда этот подход поможет вам приручить собственные сигналы. 

Есть идеи, для которых готовы попробовать собрать свою систему реактивности? Делитесь ими в комментариях и рассказывайте, над какими кастомными решениями приходилось работать самим.