Привет, Хабр! На связи Александр Чебанов, технический директор компании 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 и будьте в курсе последних новостей!