
Привет! В данной статье мы разберём процесс разработки веб-приложения на основе подхода микрофронтендов с использованием технологии 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.
В итоге, у нас получается система, где каждая команда, работающая над разными модулями имеет свое приложение в структуре микрофронтенда.
Этот подход ускоряет процесс сборки, так как больше не требуется ожидать проверки всего приложения. Код можно обновлять по частям и проводить регрессивное тестирование для каждого отдельного компонента.
Также значительно уменьшается проблема с конфликтами слияния (мердж-конфликтами), поскольку команды работают над различными частями проекта независимо друг от друга. Это повышает эффективность работы команд и упрощает процесс разработки в целом.
Тестовый стенд для демонстрации функционала и исходный код в GitHub репозитории.
Спасибо за внимание)
