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