Створення веб-додатків з використанням мікрофронтендів та Module Federation

У цій статті ми розберемо процес розробки веб-застосунку на основі підходу мікрофронтендів з використанням технології Module Federation.

Мікрофронтенди – це підхід у веб-розробці, при якому фронтенд поділяється на безліч дрібних, автономних елементів. Ці частини розробляються різними командами, мабуть, з використанням різних технологій, але в результаті вони спільно функціонують як єдине ціле. Такий підхід дозволяє вирішувати проблеми, пов’язані з великими програмами, спрощує процес розробки та тестування, сприяє використанню різноманітних технологій та покращує можливості повторного використання коду.

Мета нашого проекту – створити банківську програму, що має функціональність для перегляду та редагування банківських карток і транзакцій.

Для реалізації виберемо AntdDesign , React.js у комбінації з Module Federation

Схема роботи додатка

На схемі представлена ​​архітектура веб-додатку, що використовує мікрофронтенд з інтеграцією через Module Federation. Вгорі зображення знаходиться Host , який є головним додатком (Main app) і служить контейнером для інших додатків.

Існують два мікрофронтенди: Cards і Transactions , кожне з яких розроблено окремою командою і виконує свої функції в рамках банківського додатку.

Також на схемі є компонент Shared , який містить загальні ресурси, такі як типи даних, утиліти, компоненти та інше. Ці ресурси імпортуються як у Host, так і в мікрододатки Cards та Transactions, що забезпечує консистентність та перевикористання коду у всій екосистемі програми.

Крім того, тут зображено Event Bus , який є механізмом для обміну повідомленнями та подіями між компонентами системи. Це забезпечує спілкування між Host і мікропрограмами, а також між самими мікропрограмами, що дозволяє їм реагувати на зміни станів.

Дана схема демонструє модульну та розширювану структуру веб-додатку, що є однією з ключових переваг підходу мікрофронтендів. Це дозволяє розробляти програми, які легше підтримувати, оновлювати та масштабувати.

Ми організуємо наші програми всередині директорії packages та налаштуємо Yarn Workspaces, що дозволить нам ефективно використовувати спільні компоненти з модуля shared між різними пакетами.

"workspaces": [
    "packages/*"
  ],

Module Federation, введений Webpack 5, дозволяє різним частинам програми завантажувати код один одного динамічно. За допомогою цієї функції ми забезпечимо асинхронне завантаження компонентів

Webpack-конфіг для host-програми

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

const deps = require('./package.json').dependencies;
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  // Решта конфігурації Webpack, не пов'язана безпосередньо з Module Federation
  // ...

  plugins: [
    // Плагін Module Federation для інтеграції мікрофронтендів
    new ModuleFederationPlugin({
      remotes: {
        // Визначення віддалених мікрофронтендів, доступних для цього мікрофронтенду
        'remote-modules-transactions': isProduction
          ? 'remoteModulesTransactions@https://microfrontend.fancy-app.site/apps/transactions/remoteEntry.js'
          : 'remoteModulesTransactions@http://localhost:3003/remoteEntry.js',
        'remote-modules-cards': isProduction
          ? 'remoteModulesCards@https://microfrontend.fancy-app.site/apps/cards/remoteEntry.js'
          : 'remoteModulesCards@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        // Визначення загальних залежностей між різними мікрофронтендами
        react: { singleton: true, requiredVersion: deps.react },
        antd: { singleton: true, requiredVersion: deps['antd'] },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
        'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
        axios: { singleton: true, requiredVersion: deps['axios'] },
      },
    }),
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'src', 'index.html'), // Шаблон HTML для Webpack
    }),
  ],

  // Інші налаштування Webpack
  // ...
};

Webpack-конфіг для програми “Банківські карти”

const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const deps = require('./package.json').dependencies;

module.exports = {
  // Інші налаштування Webpack...

  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'src', 'index.html'), // Шаблон HTML для Webpack
    }),
    // Налаштування Module Federation Plugin
    new ModuleFederationPlugin({
      name: 'remoteModulesCards', // Ім'я мікрофронтенду
      filename: 'remoteEntry.js', // Ім'я файлу, який буде точкою входу для мікрофронтенду
      exposes: {
        './Cards': './src/root', // Визначає, які модулі та компоненти будуть доступні для інших мікрофронтендів.
      },
      shared: {
        // Визначення залежностей, які будуть використовуватися як спільні між різними мікрофронтендами
        react: { requiredVersion: deps.react, singleton: true },
        antd: { singleton: true, requiredVersion: deps['antd'] },
        'react-dom': { requiredVersion: deps['react-dom'], singleton: true },
        'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
        axios: { singleton: true, requiredVersion: deps['axios'] },
      },
    }),
  ],

  // Інші налаштування Webpack...
};

Тепер ми легко можемо імпортувати наші програми в host-додаток.

import React, { Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Main } from '../pages/Main';
import { MainLayout } from '@host/layouts/MainLayout';

// Лініве завантаження компонентів Cards та Transactions з віддалених модулів
const Cards = React.lazy(() => import('remote-modules-cards/Cards'));
const Transactions = React.lazy(() => import('remote-modules-transactions/Transactions'));

const Pages = () => {
  return (
    <Router>
		   <MainLayout>
          {/* Використання Suspense для керування станом завантаження асинхронних компонентів */}
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              <Route path={'/'} element={<Main />} />
              <Route path={'/cards/*'} element={<Cards />} />
              <Route path={'/transactions/*'} element={<Transactions />} />
            </Routes>
          </Suspense>
        </MainLayout>
    </Router>
  );
};
export default Pages;

Далі для команди “Банківські карти” налаштуємо Redux Toolkit

// Імпортуємо функцію configureStore із бібліотеки Redux Toolkit
import { configureStore } from '@reduxjs/toolkit';

// Імпортуємо кореневий редьюсер
import rootReducer from './features';

// Створюємо сховище за допомогою функції configureStore
const store = configureStore({
  // Встановлюємо кореневий редьюсер
  reducer: rootReducer,
  // Встановлюємо проміжне програмне забезпечення за замовчуванням
  middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
});

// Експортуємо сховище
export default store;

// Визначаємо типи для диспетчера та стану програми
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
// Імпортуємо React
import React from 'react';

// Імпортуємо головний компонент програми
import App from '../app/App';

// Імпортуємо Provider із react-redux для зв'язку React та Redux
import { Provider } from 'react-redux';

// Імпортуємо наше сховище Redux
import store from '@modules/cards/store/store';

// Створюємо головний компонент Index
const Index = (): JSX.Element => {
  return (
    // Повертаємо наш додаток у Provider, передаючи в нього наше сховище
    <Provider store={store}>
      <App />
    </Provider>
  );
};

// Експортуємо головний компонент
export default Index;

У додатку має бути система ролей:

  • USER – може переглядати сторінки,
  • MANAGER – має право на редагування,
  • ADMIN – може редагувати та видаляти дані.

Host-програма надсилає запит на сервер для отримання інформації про користувача та зберігає ці дані у своєму сховищі. Необхідно ізольовано отримати ці дані у додатку “Банківські карти”.

Для цього потрібно написати middleware для Redux-сторонка host-додатку, щоб зберігати дані в глобальний об’єкт window

// Імпортуємо функцію configureStore та тип Middleware з бібліотеки Redux Toolkit
import { configureStore, Middleware } from '@reduxjs/toolkit';

// Імпортуємо кореневий редьюсер та тип RootState
import rootReducer, { RootState } from './features';

// Створюємо проміжне програмне забезпечення, яке зберігає стан програми в глобальному об'єкті window
const windowStateMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    const result = next(action);
    (window as any).host = store.getState();
    return result;
  };

// Функція завантаження стану з глобального об'єкта window
const loadFromWindow = (): RootState | undefined => {
  try {
    const hostState = (window as any).host;
    if (hostState === null) return undefined;
    return hostState;
  } catch (e) {
    console.warn('Error loading state from window:', e);
    return undefined;
  }
};

// Створюємо сховище за допомогою функції configureStore
const store = configureStore({
  // Встановлюємо кореневий редьюсер
  reducer: rootReducer,
  // Додаємо проміжне ПЗ, яке зберігає стан у window
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(windowStateMiddleware),
  // Завантажуємо попередній стан із window
  preloadedState: loadFromWindow(),
});

// Експортуємо сховище
export default store;

// Визначаємо тип для диспетчера
export type AppDispatch = typeof store.dispatch;

Винесемо константи в модуль shared

export const USER_ROLE = () => {
  return window.host.common.user.role;
};

Для синхронізації зміни ролі користувача між усіма мікрофронтендами ми використовуємо event bus . У модулі shared реалізуємо обробники для відправки та прийому подій.

// Імпортуємо канали подій та типи ролей
import { Channels } from '@/events/const/channels';
import { EnumRole } from '@/types';

// Оголошуємо змінну для обробника подій
let eventHandler: ((event: Event) => void) | null = null;

// Функція обробки зміни ролі користувача
export const onChangeUserRole = (cb: (role: EnumRole) => void): void => {
  // Створюємо обробник подій
  eventHandler = (event: Event) => {
    // Наводимо подію до типу CustomEvent
    const customEvent = event as CustomEvent<{ role: EnumRole }>;
    // Якщо у події є деталі, виводимо їх у консоль і викликаємо callback-функцію
    if (customEvent.detail) {
      console.log(`On ${Channels.changeUserRole} - ${customEvent.detail.role}`);
      cb(customEvent.detail.role);
    }
  };

  // Додаємо обробник подій на глобальний об'єкт window
  window.addEventListener(Channels.changeUserRole, eventHandler);
};

// Функція для припинення прослуховування зміни ролі користувача
export const stopListeningToUserRoleChange = (): void => {
  // Якщо обробник подій існує, видаляємо його та обнулюємо змінну
  if (eventHandler) {
    window.removeEventListener(Channels.changeUserRole, eventHandler);
    eventHandler = null;
  }
};

// Функція для надсилання події про зміну ролі користувача
export const emitChangeUserRole = (newRole: EnumRole): void => {
  // Виводимо в консоль інформацію про подію
  console.log(`Emit ${Channels.changeUserRole} - ${newRole}`);
  // Створюємо нову подію
  const event = new CustomEvent(Channels.changeUserRole, {
    detail: { role: newRole },
  });
  // Відправляємо подію
  window.dispatchEvent(event);
};

Для реалізації сторінки редагування банківської картки, на якій враховано ролі користувачів, ми розпочнемо з встановлення механізму передплати на подію оновлення ролі. Це дозволить сторінці реагувати на зміни та адаптувати доступні функції редагування відповідно до поточної ролі користувача.

import React, { useEffect, useState } from 'react';
import { Button, Card, List, Modal, notification } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import { getCardDetails } from '@modules/cards/store/features/cards/slice';
import { AppDispatch } from '@modules/cards/store/store';
import { userCardsDetailsSelector } from '@modules/cards/store/features/cards/selectors';
import { Transaction } from '@modules/cards/types';
import { events, variables, types } from 'shared';
const { EnumRole } = types;
const { USER_ROLE } = variables;
const { onChangeUserRole, stopListeningToUserRoleChange } = events;

export const CardDetail = () => {
  // Використання Redux для диспетчеризації та отримання стану
  const dispatch: AppDispatch = useDispatch();
  const cardDetails = useSelector(userCardsDetailsSelector);

  // Локальний стан для ролі користувача та видимості модального вікна
  const [role, setRole] = useState(USER_ROLE);
  const [isModalVisible, setIsModalVisible] = useState(false);

  // Ефект для завантаження деталей картки при монтуванні компонента
  useEffect(() => {
    const load = async () => {
      await dispatch(getCardDetails('1'));
    };
    load();
  }, []);

  // Функції для керування модальним вікном
  const showEditModal = () => {
    setIsModalVisible(true);
  };

  const handleEdit = () => {
    setIsModalVisible(false);
  };

  const handleDelete = () => {
    // Відображення повідомлення про видалення
    notification.open({
      message: 'Card delete',
      description: 'Card delete success.',
      onClick: () => {
        console.log('Notification Clicked!');
      },
    });
  };

  // Ефект для передплати та відписки від подій зміни ролі користувача
  useEffect(() => {
    onChangeUserRole(setRole);
    return stopListeningToUserRoleChange;
  }, []);

  // Умовний рендеринг, якщо деталі картки не завантажені
  if (!cardDetails) {
    return <div>loading...</div>;
  }

  // Функція визначення дій з урахуванням ролі користувача
  const getActions = () => {
    switch (role) {
      case EnumRole.admin:
        return [
          <Button key="edit" type="primary" onClick={showEditModal}>
            Edit
          </Button>,
          <Button key="delete" type="dashed" onClick={handleDelete}>
            Delete
          </Button>,
        ];
      case EnumRole.manager:
        return [
          <Button key="edit" type="primary" onClick={showEditModal}>
            Edit
          </Button>,
        ];
      default:
        return [];
    }
  };

  // Рендеринг компонента Card з деталями картки та діями
  return (
    <>
      <Card
        actions={getActions()}
        title={`Card Details - ${cardDetails.cardHolderName} `}
      >
        {/* Відображення різних атрибутів картки */}
        <p>PAN: {cardDetails.pan}</p>
        <p>Expiry: {cardDetails.expiry}</p>
        <p>Card Type: {cardDetails.cardType}</p>
        <p>Issuing Bank: {cardDetails.issuingBank}</p>
        <p>Credit Limit: {cardDetails.creditLimit}</p>
        <p>Available Balance: {cardDetails.availableBalance}</p>
        {/* Список останніх транзакцій */}
        <List
          header={<div>Recent Transactions</div>}
          bordered
          dataSource={cardDetails.recentTransactions}
          renderItem={(item: Transaction) => (
            <List.Item>
              {item.date} - {item.amount} {item.currency} - {item.description}
            </List.Item>
          )}
        />
        <p>
          <b>*For demonstration events from the host, change the user role.</b>
        </p>
      </Card>
      {/* Модальне вікно для редагування */}
      <Modal
        title="Edit transactions"
        open={isModalVisible}
        onOk={handleEdit}
        onCancel={() => setIsModalVisible(false)}
      >
        <p>Form edit card</p>
      </Modal>
    </>
  );

Для налаштування розгортання програми через GitHub Actions створимо файл конфігурації .yml , який визначає робочий процес CI/CD. Ось приклад простого конфігу:

name: Build and Deploy Cards Project

# Цей workflow запускається при подіях push або pull request 
# але тільки для змін в директорії 'packages/cards'.
on:
  push:
    paths:
      - 'packages/cards/**'
  pull_request:
    paths:
      - 'packages/cards/**'

# Визначення задач (jobs) для виконання
jobs:
  # Перше завдання: Встановлення залежностей
  install-dependencies:
    runs-on: ubuntu-latest  # Завдання виконується на останній версії Ubuntu

    steps:
      - uses: actions/checkout@v2  # Выполняет checkout кода репозитория

      - name: Set up Node.js  # Встановлює Node.js версії 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кешування Node модулів для прискорення збирання
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Install Dependencies  # Встановлення залежностей проекту через Yarn
        run: yarn install

  # Друге завдання: Тестування та складання
  test-and-build:
    needs: install-dependencies  # Це завдання вимагає завершення завдання install-dependencies
    runs-on: ubuntu-latest  # Запускається на останній версії Ubuntu

    steps:
      - uses: actions/checkout@v2  # Виконує checkout коду репозиторію

      - name: Use Node.js  # використовує Node.js версії 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кешування Node модулів
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Build Shared Modules  # Складання загальних модулів
        run: yarn workspace shared build

      - name: Test and Build Cards  # Тестування та складання workspace Cards
        run: |
          yarn workspace cards test
          yarn workspace cards build

      - name: Archive Build Artifacts  # Архівація артефактів складання для розгортання
        uses: actions/upload-artifact@v2
        with:
          name: shared-artifacts
          path: packages/cards/dist

  # Третє завдання: Розгортання Cards
  deploy-cards:
    needs: test-and-build  # Це завдання вимагає завершення завдання test-and-build
    runs-on: ubuntu-latest  # Запускається на останній версії Ubuntu

    steps:
      - uses: actions/checkout@v2  # Виконує checkout коду репозиторію

      - name: Use Node.js  # Використовує Node.js версії 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кешування Node модулів
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Download Build Artifacts  # Скачування артефактів збирання з попереднього завдання
        uses: actions/download-artifact@v2
        with:
          name: shared-artifacts
          path: ./cards

      - name: Deploy to Server  # Розгортання артефактів на сервері за допомогою SCP
        uses: appleboy/scp-action@master
        with:
		  host: ${{ secrets.HOST }}
          username: root
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: 'cards/*'
          target: '/usr/share/nginx/html/microfrontend/apps'

Тут ми можемо додати такі функції, як версіонування та A/B тестування, керуючи ними через Nginx.

У результаті, у нас виходить система, де кожна команда, яка працює над різними модулями, має свою програму в структурі мікрофронтенду.

Цей підхід прискорює процес складання, оскільки більше не потрібно очікувати на перевірку всього додатка. Код можна оновлювати частинами та проводити регресивне тестування для кожного окремого компонента.

Також значно зменшується проблема із конфліктами злиття (мердж-конфліктами), оскільки команди працюють над різними частинами проекту незалежно одна від одної. Це підвищує ефективність роботи команд та спрощує процес розробки загалом.

Джерело