Микрофронтенд с использованием Module Federation. Соединяем компоненты между системами на разных фреймворках

7e94e02c1da446e0621bf1d2ba9edbda.png

Всем привет! Мы — Павел и Даниил, ведущие разработчики компании ITFB Group. У компании два собственных продукта — ЕСМ/CSP/BPM-платформа СИМФОНИЯ (документооборот, хранение контента, архив, портал) и система распознавания/обработки документов ITFB EasyDoc. Пару месяцев назад к нам прилетела задача интегрировать ряд функций распознавания из продукта ITFB EasyDoc и оформить их в отдельный модуль платформы СИМФОНИЯ, дабы пользователь всё делал в одном месте и не дрейфовал по разным системам. Однако возникла загвоздка: СИМФОНИЯ — на React, а ITFB EasyDoc — на Vue. Для решения вопроса посерчили различные источники информации и плавно ушли в собственное творчество, поскольку не обнаружили стоящих вариантов с вменяемой технической детализацией. В какой-то момент возникло острое желание поделиться нашими итоговыми наработками на Хабре и заполнить пробелы базы знаний в интернете по этому вопросу. Всем, кому интересно увидеть наше решение, добро пожаловать под кат)

Итак, этот пост посвящен интеграции между двумя решениями с использованием технологии микрофронтендов, в частности — Module Federation. Пример, который мы сейчас опишем, будет актуален для любых фреймворков: Vue, React, Angular и других. Из доступных подходов, таких как использование iframe, проксирование на уровне Nginx или микрофронтенд, мы выбрали последний как наиболее оптимальный для наших целей.

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

Для наглядности можно рассмотреть данную технологию на примере тега

Плюсы:

  • Стили и логика компонента не конфликтуют с остальной частью приложения.

  • Компонент является частью стандарта, следовательно, обеспечена полная совместимость и стабильность.

  • Есть возможность использовать компонент независимо от фреймворков, и он легко интегрируется в существующее приложение.

Минусы:

Для нашего подхода важно, что технология предоставляет возможность исполнять код в моменты инициализации и размонтирования компонента на странице с помощью хуков жизненного цикла — 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(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 
; }; 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(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 
; }; 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 || '');
  };

Итог

После выполнения всех исправлений и отладки два продукта успешно соединены визуально и функционально без видимых швов. Веб-компонент встроен на страницу и содержит необходимые разметку и логику:

2d2117f5eda59ec1fb569c21e664f16b.png

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

Минусом стало то, что в dev-сборке Vite с модульной федерацией не формирует файл remoteEntry, поэтому необходимо постоянно поддерживать стенд с собранной версией второго продукта.

Пример реализации можно посмотреть на GitHub.

© Habrahabr.ru