Привет, Хабр! На связи Александр Чебанов, технический директор компании Modus. Мы разрабатываем BI-платформу, которая собирает большие объемы данных из разных источников и представляет их в виде понятных дашбордов и отчетов для бизнеса.
Сегодня расскажу, как мы решили задачу кастомизации визуализаций под конкретных клиентов без усложнения основного кода и пересборки ядра.
Проблема: индивидуальные доработки для клиентов в общем продукте
Идея системы плагинов возникла из насущной потребности: почти каждый клиент хотел видеть в дашбордах свои уникальные визуализации. Для бизнеса это важно — такие доработки помогают лучше анализировать данные и принимать более обоснованные решения. Но включать все изменения в общий код продукта было невозможно. Он быстро бы разросся, стал сложным и неудобным в поддержке.
Сначала мы пробовали вести отдельные git‑ветки под каждого клиента. На первых порах это работало, но с каждым обновлением ядра количество конфликтов при слиянии веток (merge‑конфликтов в коде) росло, а время на их исправление увеличивалось. Это замедляло развитие всей платформы и усложняло поддержку.
В Modus BI решение оказалось классическим, но эффективным: разделить систему на ядро и плагины. Это помогло сохранить стабильность основной ветки репозитория и одновременно дать клиентам возможность подключать собственные визуализации без вмешательства в общий код.
Архитектура: что есть что?
Мы четко определили зоны ответственности.
Ядро Modus BI — это весь наш проект, который:
- предоставляет API для взаимодействия с плагинами; 
- умеет подключать и инициализировать плагины; 
- отвечает за их отображение в интерфейсе; 
- управляет данными и поддерживает безопасность. 
Плагин — это, в нашем случае, кастомная визуализация для дашборда, которая:
- общается с бекендом через выделенное API ядра; 
- обрабатывает и преобразует данные; 
- содержит собственные настройки; 
- умеет рендерить свой интерфейс в админке для конфигурации; 
- отображает уникальный виджет на дашборде. 
Ключевое условие — горячая загрузка плагинов: новый виджет должен быть доступен сразу после загрузки, без пересборки всего приложения.
Реализация: как это работает?
1. Загрузка плагинов на бекенде
Плагины загружаются в виде .tar.gz-архивов через админ-панель. Каждый архив содержит полный набор файлов: JS-бандл, CSS-стили, метаданные и обязательный манифест с описанием плагина.
После загрузки сервер автоматически разархивирует файлы и запускает проверку. Сначала он убеждается в наличии всех необходимых компонентов и корректности манифеста. Затем система анализирует целостность данных и проверяет наличие цифровой подписи, чтобы исключить запуск вредоносного кода. Также проверяются имена плагинов на предмет конфликтов с уже установленными визуализациями.
Если все проверки пройдены успешно, ассеты плагина перемещаются в отдельное пространство для статических ресурсов. Здесь они обслуживаются независимо от основного приложения. Каждому плагину присваивается версия для контроля изменений, а доступ к управлению регулируется системой прав: разные пользователи могут иметь права только на загрузку плагинов или только на их активацию в продакшн-среде.
Только после полного прохождения этого процесса плагин считается установленным и готовым к использованию.
Схематично процесс выглядит так:

2. Интеграция плагинов на фронтенде
Чтобы фронтенд понимал, что плагин загружен и готов к работе, мы используем заглушки. При сборке основного приложения резервируем места под будущие плагины в виде пустых React‑компонентов. Эти компоненты служат точками подключения, куда позже подставляется код плагина.
Вот как это работает технически:
Мы настраиваем Webpack, чтобы он не включал плагины в основной бандл, а подключал их отдельно. Конфигурация Webpack создает внешние зависимости для диапазона плагинов:
Конфигурация Webpack:
const getCustomChartExternals = (indexRange) => {  
  const output = {};  
  for (let i = indexRange[0]; i <= indexRange[1]; i += 1) {  
    output[custom-chart-${i}] = CustomChart${i};  
  }  
  return output;  
};
webpackConfig.externals = [  
  {  
    // ... другие внешние зависимости
    ...getCustomChartExternals([0, 10]),  
  },  
];Затем в index.html мы динамически подключаем все возможные плагины:
<!DOCTYPE html>  
<html lang='ru'>  
<head>
<!-- ... -->
<script type='text/javascript'>
  // ...
  const scriptPaths = [];
  for (let i = 0; i <= 40; i += 1) {  
    scriptPaths.push(${__base__}plugins/custom_chart_${i}.js);  
  }
  
  let scripts = '';
  scriptPaths.forEach((path) => {  
    scripts += <script src="${path}" defer></scr${''}ipt>;  
  });
  
  document.write(scripts);  
</script>
<!-- ... -->
</head>  
<body>  
  <div id='root'></div>
</body>  
</html>Когда реальный плагин загружается на бекенд, его JavaScript-файл перезаписывает соответствующую заглушку. Фронтенд начинает использовать новый код плагина (его рабочую реализацию) без перезагрузки всего приложения.
3. API плагина и интеграция в ядро
Для взаимодействия с ядром платформы мы определили четкий API-контракт. Каждый плагин должен экспортировать четыре основных компонента:
- CustomChart — корневой компонент для отображения виджета на дашборде; 
- CustomSettings — интерфейс настроек, который отображается в админке; 
- CustomReducers — Redux-экшен (функция, которая описывает, как изменяется состояние в Redux-хранилище) для интеграции с Redux; 
- CustomAxes — конфигурация для работы с данными в конструкторе. 
Рассмотрим на примере интеграции компонента визуализации (CustomChart).
Сначала мы все плагины централизованно импортируем в ядро:
// charts/VisualComponents.js
export { CustomChart as CustomChart0 } from 'custom-chart-0';
// ...
export { CustomChart as CustomChart10 } from 'custom-chart-10';Затем фабрика компонентов (ComponentFactory) определяет, какую именно визуализацию нужно отрендерить, и использует для этого соответствующий плагин.
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import VisualComponents from 'charts/VisualComponents';
export default class ComponentFactory extends Component {
  static propTypes = {
    component: PropTypes.object,
    config: PropTypes.object.isRequired,  
    spec: PropTypes.object,
    datas: PropTypes.object,
    pluginImports: PropTypes.object,
    // ...
  };
  render() {
    const { component, config, datas, pluginImports } = this.props;
    if (component?.type) {
      const ComponentToRender = VisualComponents[component.type];
            if (!ComponentToRender) {
        // Обработка случая, когда компонент не найден
        return <div>Компонент не найден</div>;
      }
      return (
        <ComponentToRender
          config={config}
          datas={datas}
          pluginImports={pluginImports}
          // ... другие пропсы
        />
      );
    }
    return null;
  }
}Ядро передает плагину все необходимые данные через пропсы: datas для работы с данными, config с настройками виджета и pluginImports с вспомогательными функциями для логирования, локализации и API-вызовов.
Аналогичным способом подключаются и остальные элементы плагина (CustomSettings, CustomReducers, CustomAxes), каждый в своей части приложения.
Общая схема взаимодействия выглядит так:

Чтобы управлять своим состоянием, плагин экспортирует только Redux-экшены. Интеграция происходит в готовый редюсер ядра, который отвечает за состояние редактирования визуализации.
Это дает возможность плагину записывать состояние, а отслеживание изменений происходит через обновление параметров, передаваемых в визуализацию.
Мы организовали для каждого плагина изолированную область в хранилище, используя пространства имен. Это исключает конфликты между разными плагинами и предоставляет им необходимую свободу для работы со своими данными.
При активации плагина ядро динамически заменяет redux-экшены с заглушки на redux-экшены плагина, что предотвращает конфликты. При удалении плагина redux-экшен заменяется на экшен заглушки.
Результаты и выводы
Мы получили гибкую систему, которая дает возможность:
- Разрабатывать кастомные визуализации отдельно, в независимых репозиториях. 
- Быстро разворачивать виджеты на портале клиента без пересборки ядра и деплоя всего приложения. 
- Передавать разработку плагинов клиентам, если у них есть необходимые навыки. 
Такой подход значительно упростил поддержку специфичных требований и ускорил выход обновлений основного продукта. Теперь мы без опасений принимаем уникальные пожелания заказчиков, рассматривая их как возможность расширить экосистему нашей платформы.
А как вы подходите к задачам кастомизации в ваших проектах? Сталкивались ли с подобными проблемами? Делитесь опытом в комментариях!
P.S. Присоединяйтесь к нашему BI-сообществу в Telegram и будьте в курсе последних новостей!
 
          