Всем привет! Мы — Иван и Даниил, ведущие разработчики компании ITFB Group. У компании два собственных продукта — ЕСМ/CSP/BPM-платформа СИМФОНИЯ (документооборот, хранение контента, архив, портал) и система распознавания/обработки документов ITFB EasyDoc. Пару месяцев назад к нам прилетела задача интегрировать ряд функций распознавания из продукта ITFB EasyDoc и оформить их в отдельный модуль платформы СИМФОНИЯ, дабы пользователь всё делал в одном месте и не дрейфовал по разным системам. Однако возникла загвоздка: СИМФОНИЯ — на React, а ITFB EasyDoc — на Vue. Для решения вопроса посерчили различные источники информации и плавно ушли в собственное творчество, поскольку не обнаружили стоящих вариантов с вменяемой технической детализацией. В какой-то момент возникло острое желание поделиться нашими итоговыми наработками на Хабре и заполнить пробелы базы знаний в интернете по этому вопросу. Всем, кому интересно увидеть наше решение, добро пожаловать под кат)
Итак, этот пост посвящен интеграции между двумя решениями с использованием технологии микрофронтендов, в частности — Module Federation. Пример, который мы сейчас опишем, будет актуален для любых фреймворков: Vue, React, Angular и других. Из доступных подходов, таких как использование iframe, проксирование на уровне Nginx или микрофронтенд, мы выбрали последний как наиболее оптимальный для наших целей.
В реализации мы применяли технологии веб-компонентов и модульной федерации. Веб-компоненты представляют собой набор API веб-платформы, позволяющий разработчикам создавать настраиваемые, многоразовые и инкапсулированные HTML-теги для использования на веб-страницах и в веб-приложениях.
Для наглядности можно рассмотреть данную технологию на примере тега <video>. Это стандартный веб-компонент, который объединяет множество функций и разметки, такие как стилизованные кнопки управления, логика загрузки, воспроизведения и паузы. Компонент позволяет нам взаимодействовать через атрибуты, и на нем легко понять все плюсы и минусы веб-компонентов.
Плюсы:
Стили и логика компонента не конфликтуют с остальной частью приложения.
Компонент является частью стандарта, следовательно, обеспечена полная совместимость и стабильность.
Есть возможность использовать компонент независимо от фреймворков, и он легко интегрируется в существующее приложение.
Минусы:
Ограниченная поддержка браузерами.
Сложность кастомизации стилей извне.
Для нашего подхода важно, что технология предоставляет возможность исполнять код в моменты инициализации и размонтирования компонента на странице с помощью хуков жизненного цикла — connectedCallback и disconnectedCallback, — а также объявлять атрибуты и отслеживать их изменения. Это позволит инициализировать Vue-приложение с требуемым функционалом.
Базовый пример инициализации веб-компонента выглядит следующим образом:
class ExampleComponent extends HTMLElement {
connectedCallback() {
// cb вызывается при инициализации на странице
/** исполняемый код **/
}
disconnectedCallback() {
// cb вызывается при удалении со страницы
/** исполняемый код **/
}
static get observedAttributes() {
return ['example’]; // массив зависимостей, для которых будет отслеживаться изменение и вызываться cb attributeChangedCallback
}
attributeChangedCallback(name, oldValue, newValue) {
/** исполняемый код **/
}
}
/** Добавление в CustomElementRegistry, добавляет глобально для приложения **/
customElements.define('example-component', ExampleComponent)
Module Federation — функция, которая предоставляет возможность JavaScript-приложению динамически загружать код из другого приложения во время выполнения. Module Federation позволяет независимым частям продукта делиться зависимостями, библиотеками или компонентами, что помогает уменьшить общий размер пакета и оптимизировать время загрузки. Данную технологию мы будем использовать для загрузки предварительно подготовленного веб-компонента.
Решение
Давайте начнем с подготовки встраиваемого компонента на Vue. Для этого будем использовать веб-компонент. В методе connectedCallback мы инициализируем экземпляр Vue-приложения (VerificationApp), а в disconnectedCallback — удалим приложение из памяти при удалении веб-компонента со страницы. Заодно устраним ошибки и немного перепишем код, чтобы он был более читабельным и аккуратным:
import Vue from 'vue'
import store from './vuex'
Vue.config.productionTip = false
Vue.use(...)
/* eslint-disable no-new */
const ConnectedApp = Vue.extend({
created: function () {
/** бизнес логика **/
},
beforeDestroy() {
/** бизнес логика **/
},
methods: {
/** бизнес логика **/
},
render: (h) => h(App),
store
})
export class VerificationPackageService extends HTMLElement {
instance = null;
connectedCallback() {
this.instance = new VerificationApp({
/** здесь будем дополнять **/
}).$mount()
this.appendChild(this.instance.$el)
}
disconnectedCallback() {
this.instance.$destroy();
this.removeChild(this.instance.$el)
this.instance = null;
}
}
customElements.define('verification-package-service', VerificationPackageService)
Включаем и настраиваем плагин для работы с Module Federation в Vite:
npm i –save-dev @originjs/vite-plugin-federation
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
/** остальная часть конфига **/
plugins: [
federation({
name: "verification-package-service",
filename: "remoteEntry.js",
exposes: {
'./entry': './src/main.js'
},
})
],
})
В React-приложении необходимо установить плагин для работы с Module Federation и указать путь до Vue-продукта:
import federation from '@originjs/vite-plugin-federation';
export default ({ mode }) => {
return defineConfig({
/** остальная часть конфига **/
plugins: [
federation({
name: 'host-app',
remotes: {
'verification-package-service': ‘https://…/assets/remoteEntry.js’,
},
}),
],
});
};
Для успешного подключения в продукт необходимо выполнить несколько шагов:
Добавляем отдельный роут.
В компоненте, который соответствует конкретному роуту, инициализируем React-компонент EasyDocWrapper. Этот компонент будет отвечать за загрузку и отображение Vue-компонента. Сначала объявим useRef для получения ссылки на DOM-элемент, в который будет добавлен веб-компонент (Verification Package Service). Затем функция LoadModule будет асинхронно загружать Vue-компонент и встраивать его в React-приложение. Это обеспечит гладкую интеграцию и изолированное взаимодействие между двумя фреймворками.
import { memo, useEffect, useRef } from 'react';
const EasyDocWrapper = () => {
const ref = useRef<HTMLDivElement | null>(null);
const loadModule = async () => {
if (isLoaded.current) {
return;
}
isLoaded.current = true;
await import('verification-package-service/entry');
const element = document.createElement('verification-package-service');
ref.current?.appendChild(element);
};
useEffect(() => {
loadModule();
}, []);
return <div ref={ref} />;
};
export default memo(EasyDocWrapper, () => true);
После инициализации у нас есть функционирующий, но пока статичный вариант. Однако в текущем состоянии мы не можем обмениваться данными между приложениями или обрабатывать события.
Для передачи событий мы будем использовать небольшой самописный класс. Мы выбрали самый простой подход, но в дальнейшем можем усложнить его — например, используя EventEmitter и прочее. Логику мы добавим в React-приложение. Для возможности использования в другом продукте мы добавим экземпляр в глобальную переменную:
import { memo, useEffect, useRef } from 'react';
type EasyDocEventEmitter = Window &
typeof globalThis & {
easyDocEvents: any;
};
const EasyDocWrapper = () => {
const ref = useRef<HTMLDivElement | null>(null);
…
const initEventEmitter = () => {
if ((window as EasyDocEventEmitter).easyDocEvents) {
return;
}
class EasyDocEvents extends EventTarget {
closePackage() {
…
}
verifyPackage() {
…
}
dispatchErrorStatus(statusCode: number) {
…
}
}
const instance = new EasyDocEvents();
(window as EasyDocEventEmitter).easyDocEvents = instance;
};
const removeEventEmitter = () => {
delete (window as EasyDocEventEmitter).easyDocEvents;
};
useEffect(() => {
…
initEventEmitter();
return () => {
removeEventEmitter();
};
}, []);
return <div ref={ref} />;
};
export default memo(EasyDocWrapper, () => true);
В приложении на Vue.js вызываем объявленные методы:
const VerificationApp = Vue.extend({
methods: {
goToReactApp(type) {
try {
if (type === "verify") {
window.easyDocEvents?.verifyPackage?.()
}
if (type === "close") {
window.easyDocEvents?.closePackage?.()
}
} catch {...}
},
},
…
})
Для передачи данных между компонентами отлично подходят атрибуты. Если необходимо отслеживать изменения атрибутов, можно усложнить логику с использованием методов attributeChangedCallback.
Пример инициализации атрибута в продукте Vue:
export class ConnectedAppService extends HTMLElement {
connectedCallback() {
this.instance = new VerificationApp({
data: {
packageId: this.getAttribute("package-id") // props
}
}).$mount()
this.appendChild(this.instance.$el)
}
…}
Использование и передача данных в React-приложении:
const loadModule = async () => {
…
const element = document.createElement('verification-package-service');
element.setAttribute('package-id', packageId || '');
};
Итог
После выполнения всех исправлений и отладки два продукта успешно соединены визуально и функционально без видимых швов. Веб-компонент встроен на страницу и содержит необходимые разметку и логику:
Плюсом является то, что сборки продуктов полностью независимы и могут деплоиться и храниться в любом месте при условии правильно указанного пути до remoteEntry.js в конфигурации.
Минусом стало то, что в dev-сборке Vite с модульной федерацией не формирует файл remoteEntry, поэтому необходимо постоянно поддерживать стенд с собранной версией второго продукта.
Пример реализации можно посмотреть на GitHub.