Причина
Причина, по которой решено было внедрять микросервисный подход на фронте, довольно простая - много команд, а проект один, нужно было как-то разделить зоны ответственности и распараллелить разработку. Как раз в тот момент, мне на глаза попался доклад Павла Черторогова про Webpack 5 Module Federation. Честно, это перевернуло моё видение современных веб-приложений. Я очень вдохновился и начал изучать и крутить эту технологию, чтобы понять, можно ли применить это в нашем проекте. Оказалось, всё что нужно, это дописать несколько строк в конфиг Webpack, создать пару компонентов-хелперов, и... всё завелось.Настройка
Итак, что же нужно сделать, чтобы запустить микрофронтенды на базе сборки Webpack 5?Для начала, убедитесь, что используете Webpack пятой версии, потому что Module Federation там поддерживается из коробки.
Настройка shell-приложения
Так как, до внедрения микрофронтендов, у нас уже было действующее приложение, решено было использовать его в качестве точки входа и оболочки для подключения других микрофронтендов. Для сборки использовался Webpack версии 4.4 и при обновлении до 5 версии возникли небольшие проблемы с некоторыми плагинами. К счастью, это решилось простым поднятием версий плагинов.Чтобы создать контейнер на базе сборки Webpack и при помощи этого контейнера иметь возможность импортировать ресурсы с удаленных хостов добавляем в Webpack-конфиг следующий код:
const webpack = require('webpack');
// ...
const { ModuleFederationPlugin } = webpack.container;
const deps = require('./package.json').dependencies;
module.exports = {
// ...
output: {
// ...
publicPath: 'auto', // ВАЖНО! Указывайте либо реальный publicPath, либо auto
},
module: {
// ...
},
plugins: [
// ...
new ModuleFederationPlugin({
name: 'shell',
filename: 'shell.js',
shared: {
react: { requiredVersion: deps.react },
'react-dom': { requiredVersion: deps['react-dom'] },
'react-query': {
requiredVersion: deps['react-query'],
},
},
remotes: {
widgets: `widgets@http://localhost:3002/widgets.js`,
},
}),
],
devServer: {
// ...
},
};
Теперь нам нужно забутстрапить точку входа в наше приложение, чтобы оно запускалось асинхронно, для этого создаем файл bootstrap.tsx и кладем туда содержимое файла index.tsx
// bootstrap.tsx
import React from 'react';
import { render } from 'react-dom';
import { App } from './App';
import { config } from './config';
import './index.scss';
config.init().then(() => {
render(<App />, document.getElementById('root'));
});
А в index.tsx вызываем этот самый bootstrap
import('./bootstrap');
В общем то всё, в таком виде уже можно импортировать ваши микрофронтенды - они указываются в объекте remotes в формате <name>@<адрес хоста>/<filename>. Но нам такая конфигурация не подходит, ведь на момент сборки приложения мы ещё не знаем откуда будем брать микрофронтенд, к счастью, есть готовое решение, поэтому возьмем код из примера для динамических хостов, так как наше приложение написано на React, то оформим хэлпер в виде React-компонента LazyService:
// LazyService.tsx
import React, { lazy, ReactNode, Suspense } from 'react';
import { useDynamicScript } from './useDynamicScript';
import { loadComponent } from './loadComponent';
import { Microservice } from './types';
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary';
interface ILazyServiceProps<T = Record<string, unknown>> {
microservice: Microservice<T>;
loadingMessage?: ReactNode;
errorMessage?: ReactNode;
}
export function LazyService<T = Record<string, unknown>>({
microservice,
loadingMessage,
errorMessage,
}: ILazyServiceProps<T>): JSX.Element {
const { ready, failed } = useDynamicScript(microservice.url);
const errorNode = errorMessage || <span>Failed to load dynamic script: {microservice.url}</span>;
if (failed) {
return <>{errorNode}</>;
}
const loadingNode = loadingMessage || <span>Loading dynamic script: {microservice.url}</span>;
if (!ready) {
return <>{loadingNode}</>;
}
const Component = lazy(loadComponent(microservice.scope, microservice.module));
return (
<ErrorBoundary>
<Suspense fallback={loadingNode}>
<Component {...(microservice.props || {})} />
</Suspense>
</ErrorBoundary>
);
}
Хук useDynamicScript нужен нам, чтобы в рантайме прикреплять загруженный скрипт к нашему html-документу.
// useDynamicScript.ts
import { useEffect, useState } from 'react';
export const useDynamicScript = (url?: string): { ready: boolean; failed: boolean } => {
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!url) {
return;
}
const script = document.createElement('script');
script.src = url;
script.type = 'text/javascript';
script.async = true;
setReady(false);
setFailed(false);
script.onload = (): void => {
console.log(`Dynamic Script Loaded: ${url}`);
setReady(true);
};
script.onerror = (): void => {
console.error(`Dynamic Script Error: ${url}`);
setReady(false);
setFailed(true);
};
document.head.appendChild(script);
return (): void => {
console.log(`Dynamic Script Removed: ${url}`);
document.head.removeChild(script);
};
}, ); return { ready, faile...пример, [URL]http://localhost:3002/widgets.js), с которого мы хотим подтянуть модуль
[*]scope - параметр name, который мы укажем в удаленном конфиге ModuleFederationPlugin
[*]module - имя модуля, который мы хотим подтянуть
[*]props - опциональный параметр, если вдруг наш микросервис требует пропсы, нужно их типизировать
[/LIST]
Вызов компонента LazyService происходит следующим образом:
import React, { FC, useState } from 'react';
import { LazyService } from '../../components/LazyService';
import { Microservice } from '../../components/LazyService/types';
import { Loader } from '../../components/Loader';
import { Toggle } from '../../components/Toggle';
import { config } from '../../config';
import styles from './styles.module.scss';
export const Video: FC = () => {
const [microservice, setMicroservice] = useState<Microservice>({
url: config.microservices.widgets.url,
scope: 'widgets',
module: './Zack',
});
const toggleMicroservice = () => {
if (microservice.module === './Zack') {
setMicroservice({ ...microservice, module: './Jack' });
}
if (microservice.module === './Jack') {
setMicroservice({ ...microservice, module: './Zack' });
}
};
return (
<>
<div className={styles.ToggleContainer}>
<Toggle onClick={toggleMicroservice} />
</div>
<LazyService microservice={microservice} loadingMessage={<Loader />} />
</>
);
};
В общем то, по коду видно, что мы можем динамически переключать наши модули, а основной url хранить, например, в конфиге.
Так, с shell-приложением вроде разобрались, теперь нужно откуда-то брать наши модули.
Настройка микрофронтенда
Для начала проделываем все те же манипуляции что и в shell-приложении и убеждаемся, что версия Webpack => 5Настраиваем ModuleFederationPlugin, но уже со своими параметрами, эти параметры указываем при подключении модуля в основное приложение.
// ...
new ModuleFederationPlugin({
name: 'widgets',
filename: 'widgets.js',
shared: {
react: { requiredVersion: deps.react },
'react-dom': { requiredVersion: deps['react-dom'] },
'react-query': {
requiredVersion: deps['react-query'],
},
},
exposes: {
'./Todo': './src/App',
'./Gallery': './src/pages/Gallery/Gallery',
'./Zack': './src/pages/Zack/Zack',
'./Jack': './src/pages/Jack/Jack',
},
}),
// ...
В объекте exposes указываем те модули, которые мы ходим отдать наружу, точку входа в приложение так же нужно забутстрапить. Если в микрофронтенде нам не нужны модули с других хостов, то компонент LazyService тут не нужен.
Вот и всё, получен работающий прототип микрофронтенда.
Выглядит круто, работает тоже круто. Общие зависимости не грузятся повторно, версии библиотек рулятся плагином, можно динамически переключать модули, в общем, сказка. Если копать глубже, то это очень гибкая технология, можно использовать её не только с React и JavaScript, но и со всем, что переваривает Webpack, то есть теоретически можно подружить части приложения написанные на разных фреймворках, это конечно не очень хорошо, но сделать так можно. Можно собрать модули и положить на CDN, можно использовать контейнер как общую библиотеку компонентов для нескольких приложений. Возможностей реально много.
Проблемы
Когда удалось запустить это в нашем проекте я был доволен, нет, очень доволен, но это длилось недолго, после того как началась реальная работа над микрофронтендами, начали всплывать наши любимые подводные камни, а теперь поговорим он них подробнее.Потеря контекстов в React-компонентах
Как только понадобилось работать с контекстом библиотеки react-router, то возникли проблемы, при попытке использовать в микрофронтенде хук useLocation, например, приложение вылетало с ошибкой.Для взаимодействия с бэкендом мы используем Apollo, и хотелось, чтобы ApolloClient объявлялся только единожды в shell-приложении. Но при попытке из микрофронтенда просто использовать хук useQuery, в рантайме приложение вылетало с такой же ошибкой как и для useLocation.
Экспериментальным путём было выяснено, для того чтобы контексты правильно работали, нужно в микрофронтендах использовать версию npm-пакета не выше, чем в shell-приложение, так что за этим нужно внимательно следить.
Дублирование UI-компонентов в shell-приложении и микрофронтенде
Так как разработка ведётся разными командами, есть шанс, что разработчики напишут компоненты с одинаковым функционалом и в shell-приложении и в микрофронтенде. Чтобы этого избежать, есть несколько решений:- Выносить UI-компоненты в отдельный npm-пакет и использовать его как shared-модуль
- "Делиться" компонентами через ModuleFederationPlugin
Заключение
Пока что выглядит так, что переход на Webpack 5 Module Federation решает проблему, которая стояла перед нашим стримом, а именно - разделение зоны ответственности и распараллеливание разработки. При этом, нет больших накладных расходов при разработке, а настройка довольно проста даже для тех, кто не знаком с этой технологией.Минусы у этого подхода конечно же есть, накладные расходы для развертывания зоопарка микрофронтендов будут значительно выше, чем для монолита. Если над вашим приложением работает одна-две команды и оно не такое большое, то наверное не стоит делить его на микросервисы.
Но для нашей конкретной проблемы, это решение подошло хорошо, посмотрим, как оно покажет себя в будущем, технология развивается и уже появляются фреймворки и библиотеки, которые под капотом используют Module Federation.
Источник статьи: https://habr.com/ru/post/554682/