О микрофронтендах и сопутствующей концепции Model Federation на примере большого проекта.
Архитектура микрофронтов до Module Federation
Привет, меня зовут Степан, я главный frontend-разработчик в Альфа-Банке. Проектом, о котором пойдёт речь, занимается наша команда. Только фронтенд-разработчиков в ней 60. Множество команд поддерживают более 50 приложений, приносящих прибыль бизнесу.
У нас ранее были микрофронты, но они были построены не на WMF. Не вдаваясь в документацию, давайте покажу, как всё было устроено, чтобы описать причины переезда. Думаю, будет интересно, учитывая, что проект большой.
Вот так выглядит приложение.
На изображении виден remote и host. В host’е снизу тапбар, сверху информация о картах. В него может встроиться абсолютно всё, что угодно. Например, на мобилке видно, что есть дашборд, а справа платежи. Таких приложений 50 и каждое катится отдельно.
Каждое remote-приложение запускается путём определения методов по appID: __mount
и __unmount
.
Этот метод (впоследствии) просто рендерил приложение React.createElement
по какой-либо ноде, которая лежала в хосте.
Также в этом приложении у нас был некий Node.js слой (api getAssetsAndConfig
), который отдавал статику и конфиг. В основном это были ссылки до статики с js и css, а также фичи с бека.
Эту статику запрашивал host и вставлял её в dom.
Тем самым после загрузки мы брали и вызывали те самые методы __mount
и __unmount
: __mount
вызывался — приложение рендерилось.
Всё, микрофронты готовы.
При __unmount
мы удаляли те самые скрипты и JS-файлы, тем самым боролись с изоляцией стилей. Конфликт стилей — это больная тема. Но об этом позже.
Что здесь не так?
№1. Нет возможности переиспользовать зависимости.
Из-за этого на некоторых приложениях бандл достигал 1 мб br, что ненормально для одного монолитного приложения, а у нас тут один микрофронт весит столько. А всего их 50.
№2. Вызов getAssetsAndConfig
не давал возможности уменьшить водопад запросов и закешировать запрос до конфига.
Это был BFF-слой, мидловая ручка, к которой мы всегда стучались. Её хотелось бы закешировать в рамках одной раскатки, чтобы вышла одна версия и закешировалась до конца. С нашим подходом так не получится.
№3. Самописный фреймворк.
На его поддержку уходило достаточно много времени. Смотря на скорость загрузки и размер файлов, мы поняли, что пора. Пора переходить на общий концепт. И самым подходящим вариантом стал Module Federation.
Почему Module Federation?
№1. Не надо сильно менять сборку проекта.
Добавляется WMF как плагин Webpack, а он у нас и так был. Добавить новый плагин — казалось бы, не самая большая проблема.
№2. WMF — это плагин с открытым исходным кодом.
Можно посмотреть в кишки технологии на GitHub, которую используешь.
№3. Позволяет переиспользовать зависимости.
Мы можем переиспользовать даже самые маленькие зависимости вроде иконок или шрифтов. Главное, чтобы инфраструктура потянула столько одновременных запросов. Это киллер-фича, из-за которой, наверное, все выбирают этот подход.
№4. Устоявшийся продукт на рынке с огромным количеством примеров.
Есть репозиторий, в нём лежат примеры от простого до сложного — всё, что можно использовать.
№5. Есть реализация на других популярных сборщиках, таких как vite, rspack, rollup.
Сейчас большие компании отказываются от Webpack в пользу каких-то более быстрых, например, Nexts пишет Turbopack. Не хотелось бы упираться в конкретный плагин на Webpack, чтобы потом опять всё переписывать. А WMF, как некий общий концепт, есть на многих сборщиках.
№6. Встраивание происходит обычным JS-скриптом, который можно закешировать.
У нас приложения крутятся в Kubernetes: отдельное приложение — отдельный контейнер. С таким подходом далеко не уедешь. Надо обязательно переходить на S3, менять процесс деплоя.
Этот пункт нас избавляет от проблемы наличия BFF-слоя. И мы, наконец, можем съехать с Kubernetes (а с него сейчас съезжать очень тяжело), и не иметь этого Node JS слоя.
№7. Возможность шарить общие сторы, например react-query. Можно зашарить контекст через определенные поля в плагине и он будет доступен во всех микрофронтах.
Итак, мы решили, что нам нужен WMF. Как внедрять?
Кратко о внедрении
Для встраиваемых приложений изменяется структура папок.
Главные из них — это bootstrap.tsx
, remote.tsx
и index.ts
.
Посмотрим на них подробнее.
remote.tsx
Один из трёх файлов, что отдаётся наружу и встраивается в host.
Ниже мы как раз говорим: «Вот этот кусок кода будет использоваться снаружи».
Его будет использовать host, для которого мы в Webpack прописываем exposes
.
Два остальных файла нужны сугубо для локальной разработки, чтобы поднять проект и дописать что-то своё.
index.ts
Этот файл состоит всего из одной строки, где мы импортим bootstrap-файл.
Настраиваем Webpack на эти пути.
bootstrap.tsx
А bootstrap.ts
, в свою очередь, поднимает приложение локально в standalone режиме, чтобы иметь возможность разрабатывать только свой функционал или прогнать тесты на CI.
В InitNewclickDevEntrypoint
видно, что мы просто рендерим приложение, например, есть утилиты @axe-core/react
для юзабилити. В общем, есть всё, чтобы запустить проект индивидуально.
Не слишком ли много кода для приложения?
Очевидно, что столько кода в каждом приложении держать нельзя. Поэтому всё вносится в отдельный пакет, который называется «пресеты».
“devDependencies”: {
“@alfa-bank/newclick-arui-scripts-presets”: “9.14.1”,
В него скидываются все Webpack конфиги и модули, которые мы хотим шарить. Чтобы перевести на новое приложение, разработчику достаточно обновить @alfa-bank/newclick-arui-scripts-presets
и поменять структуру папок по шаблону.
Плагин WMF
Вот как раз и они — модули. Настройки лежат в общей библиотеке, где мы можем настраивать конфиги сразу для всех приложений.
На первой итерации мы шарим только react, react-dom, какие-то роуты — большие библиотеки, которые используются и изменяются достаточно редко.
Путь до ассетов
Прописываем publicPath
.
output: {
…newConfig.output,
publicPath: ‘/${name}/assets/’,
},
Это важно, потому что Model Federation записывает этот публичный путь как раз таки в remoteEntry
. Он показывает, куда идти до /${name}/assets
.
Например, у нас была проблема, что при прошлой архитектуре путь был динамическим. И когда мы стали использовать WMF, то два приложения грузились одновременно и начали конфликтовать. Пришлось отказаться от «динамики».
Кеширование remoteEntry
На первой итерации на Nginx мы прописали максимальный кэш файла в 15 минут, чтобы пользователь мог чувствовать себя комфортно в рамках сессии.
Но api Webpack позволяет не только захардкодить remoteEntry.js
, но и добавить к нему контент хеш, чтобы при последующих загрузках приложение существовало в рамках одного релиза. Зарелизился, прошло неограниченное количество времени, а у пользователя до сих пор в хеше лежит этот файлик.
Host
А вот в host мы подгружаем приложение не через плагин, а динамически (чуть дальше расскажу почему). Пока что просто обозначу, что добавляем ModuleFederationPlugin
— определяем все те же модули, которые хотим шарить.
Хост у нас рендерится на сервере, а remote приложения — на клиенте.
new NodeFederationPlugin(serverWmnfPluginOptions, {}),
new StreamingTargetPlugin(serverWmnfPluginOptions),
…
export const serverWmfPluginOptions = {
name,
library: { type: ‘commonjs-module’ },
};
И если запустить это просто так, то нода будет ругаться: ««Ты подсовываешь api, которого у меня нет». Поэтому у нас есть такие два плагина-заглушки, по крайней мере, пока у нас нет серверного рендеринга для MF.
Немного о динамической загрузке
В ней есть масса плюсов.
У нас есть детальный доступ до api WMF, мы можем определять скоуп или подгружать чанки.
Нет загрузки всех
remoutEntry.js
файлов. Это важно, когда приложений очень много. Если прописать все приложения в плагине, то при сборке проекта все файлы загрузятся на старте. А пользователю столько просто не нужно.Самое важное — приложению нет необходимости знать о том, что в нём будет в момент сборки. Конфиг можно подтянуть с мидла (и вообще откуда угодно).
Как встроить приложение
Для этого нам необходим remoteEntry.js
файл. В нём хранится вся информация о чанках, пакетах, которые мы можем переиспользовать, куда идти до ассетов и многое другое.
Из официального примера добавим загрузку этого скрипта в dom.
После загрузки используем api WMF.
Здесь:
Инициализируем
__webpack_init_sharing
— это общий скоуп зависимостей.Инициализируем наш контейнер, который как раз таки загружается в
remoteEntry.js
файле.Инициализируем общие модули в
remoteEntry.js
файле.Снаружи загружаем чанки, которые нам нужны.
И получаем модуль.
После у нас загружается вся необходимая статика, чанки взяли из общего скоупа или они загрузились из приложения.
const Remote = await loadWmfComponent(scope, module);
Можно его просто расценивать как лейзи компонент в host, который мы загрузили. В принципе, именно так мы его и используем.
Осталось вставить его в рендеринг и всё готово.
React.lazy(memo(RemoteModule.default));
Как работать с роутингом
Если очень кратко, то у нас используется api react-router пятой версии. Мы подписываемся и на host, и на remote приложение: следим за всеми изменениями в них, соответственно.
Как следствие, каждый знает куда он пошёл и зачем, потому что, например, в remote приложении может быть много страниц, а у host есть navbar. Они между собой общаются, и получается та самая магия, когда все друг о друге знают и нет никаких проблем.
Но, конечно, не может быть всё так просто.
«Просто» проблемы и проблемы специфичные
Если про WMF есть доклад на конференции или статья на Хабре, то, скорее всего, там будет блок про ошибки. И самая популярная ошибка, которой очень часто встречается в докладах — это shared module is not available for eager consumption
: когда надо настроить эти два файла для локальной разработки, иначе Webpack не помечает файлы как асинхронные и не может зашарить эти модули.
А есть ошибки, о которых на конференциях не говорят. Просто потому, что они для каждого проекта специфичные. Давайте расскажу о тех, что вылезли у нас.
Split chunks
В проекте у нас реализован сплит чанков для долгого кэширования: разделяем какие-то чанки на группы, и пользователи долго живут в кеше. Достаточно большая тема, но с WMF надо подчеркнуть две главные особенности.
Для нахождения чанка нам в api плагина для expose
надо прописать название чанка в момент сборки, чтобы при последующем сплите мы могли его найти. Нигде в документации api WMF про это не написано, что expose
можно передать не только как строку, а ещё и как объект с полем name
, которым является тот самый entrypoint. Но мы нашли это свойство в плагине.
Второе: когда проект собирается у нас под капотом у нас два entrypoint: базовый, которые мы прописали в Entry Webpack, и тот, что прописывается в поле expose
в плагине MF. Это можно увидеть, если, например, написать кастомный хук и заметить, что он видит, что это отдельный entrypoint.
Для этого мы прописали вот этот «magic comments» от Webpack, чтобы найти эти чанки и разделить.
А в самом Webpack надо настроить фильтр для всех entry.
Так мы находим первые два десктопа по entry.
Это наш грейд: используем базовые библиотеки, чтобы объединить в один чанк, который редко обновляется.
css-конфликты
С микрофронтами есть две основные проблемы.
№1. Каждый проект имеет свои стили: кастомные и те, что экспортируются из ui-библиотеки.
Если не выстроить в правильном порядке загрузку, то стили библиотеки будут переписывать кастомные. Особенно это заметно, когда, например, грузишь два микрофронта и стили библиотек добавляются в конец, перезаписывая стили всех остальных загруженных приложений.
Если использовать большой проект, то api mini-css contract плагина самостоятельно понимает, какие чанки идут впереди ui-ных библиотек. С микрофронтами так не получится.
№2. Версии библиотек разнятся: разные ui-библиотеки, разные отступы, это может всё конфликтовать и куча багов вылезает.
Нам стало понятно, что стили надо изолировать в рамках приложения, делать их просто уникальными, чтобы при раскатке одного ты не тегал другую команду.
Есть два решения.
Первый вариант изоляции стилей — это Shadow dom.
Но у него есть свои проблемы: с монтированием стилей, с доступностью общего контекста из него, придумыванием решения по шарингу библиотек со стилями. Ибо если закинуть стили Shadow dom, то они:
во-первых, удалятся из него;
а во-вторых, нет никакого доступа из другого Shadow dom до него.
Много проблем.
Второй вариант — сделать сами классы, всё, что касается стилей, уникальным от проекта к проекту.
Вот этот вариант мы и выбрали и используем api css-loader. Уникальность стилей достигается путем добавления hash приложения в названия классов каждого (класса).
Но здесь важно, чтобы все стили, которые вы используете, были импортированы как css module, потому что Webpack просто не сможет найти то, что вы хотите поменять.
Для добавления hash используем api css loader для Webpack, а если точнее — функцию getLocalIdent
. Через неё мы имеем доступ к конфигурированию каждого класса css модулей.
Реализация выглядит достаточно сложно (здесь часть).
Но можно увидеть, что в конце класса мы добавляем hash значение app name из package.json
, тем самым индивидуализируя наши стили. В коде получаем значения ниже, когда ко всем классам добавлен hash f7abfc
.
Но что, если у вас будет библиотека, которая использует css vars или обычный импорт стилей без css module, до которого нет доступа из Webpack?
Для нас такой библиотекой стал swiper, стили которого импортируются как styles.css
файл. До неё нет никакого доступа из Webpack.
Кроме того у нас есть наша ui-библиотека, которая построена на css-переменных, и они точно будут конфликтовать из-за разных версий. Их тоже надо делать уникальными.
Решение — кастомный postcss плагин, который добавляет больше специфичности для таких стилей, как swiper, и изменяет названия переменных с префиксом приложения.
Кратко: проходимcя по всем переменным и добавляем им префикс.
Для css-переменных также: проходимся и добавляем префикс.
Как это выглядит — . newclick-referal-ui
. Просто чуть выше ставим ID приложения, тем самым добиваемся уникальности.
А с переменными видно, что они уникальные от проекта к проекту.
Подытожим со стилями:
Уникальные стили для каждого приложения решили все проблемы с конфликтами.
Оставили возможность шарить ui-компоненты через WMF. Это важно, потому что, например, хочется зашарить какую-то кнопочку или сайд панель, а она весит много и используется почти во всех проектах. С Shadow dom не было бы возможности переиспользования, пришлось бы опять что-то костылить.
Стили всегда остаются в памяти в рамках сессии, что чуть улучшает перформанс. Мы ходим по проектам и они также лежат у нас в памяти.
Вроде всё заработало и можно переезжать?
Как переезжать?
№1. Весь код в приложениях максимально вынести в отдельные библиотеки.
Мы в принципе так и сделали: всё, что связано с Webpack, с линтером, ушло в библиотеку. Просто достаточно её обновить, сделать какие-то шаблоны и действия, и проект запустится.
№2. Написать правило в линтер с определённым дедлайном, после чего будут валиться билды.
Во время каждого коммита напоминайте людям, что надо переводить, иначе скоро поставки могут быть заблочены.
№3. Добавить возможность загружать приложение старым и новым способом.
Сейчас вот об этом и поговорим.
Обратная совместимость
В host-ui есть общий конфиг приложений, где мы по флагу WMF смотрим, как контракт поддерживает приложение. Конфиг приложения подтягивается в таком формате:
У каждого приложения есть конфиг с appID
и contextRoot
, для него добавляется флаг wmf: true
. По нему мы как раз и смотрим, какой контракт поддерживает приложение.
В зависимости от контракта идём за remoteEntry
. Если он не скачивает скрипты — вызываем старый контракт.
У данного функционала есть большой минус: при обновлении приложения на новый контракт, надо катить оба и вместе, иначе будут сыпаться ошибки на 404 remoteEntry.js
. Хотя всё и будет работать, но в логах останется куча запросов к несуществующей ручке.
Цифры и метрики
Конечно, мы всё замерили до и после, чтобы сравнить.
На основных приложениях увидели, что прирост бандла сократился на треть, с учетом того, что мы шарим пока только основные библиотеки, которые обновляются редко.
Скорость загрузки приложения также увеличилась — в среднем на 45–50% по дашбордам. Цифры рассчитываем на дистанции, с учётом того, что пользователь ходит по приложению и всё больше зависимостей переиспользуется. Например, libphonenumber
нет в каком-то проекте: он подтянулся, пошёл в следующее, но также остаётся в памяти.
Пара примеров того, что получилось.
Главный экран.
Страница переводов.
Это хороший результат, с учётом сокращения бандла и отказа от bmf слоя, а точка входа — это просто js-файл весом в 3 Кб, что немного.
Итого
Кастомный подход к микрофронтам вызывал проблемы с производительностью: каждое приложение загружалось ощутимо долго. Сейчас ситуация изменилась: приложение стало быстрее загружаться. На скринкасте видно, как изменилась загрузка.
Мы выбрали WMF по нескольким причинам:
Его очень просто внедрить в систему. Даже не рассматривали ничего другого.
У WMF очень много возможностей.
Концепт ощутимо развивается: добавляется в next.js, rspack, server-components. В гайдлайн, что они выстроили, пишут, что хотят добавить, и там много всего интересного.
Ну а для себя мы подчеркнули главные пути развития микрофронтендной архитектуры:
CDN: у нас банк и на CDN завязано много важных доработок, например, на подмену скриптов. С WMF это невозможно сделать. Точнее можно, но плагины, которые это используют, валятся. Приходится дорабатывать.
Discovery service. Это как раз-таки про
remoteEntry.js
файл. Мы хотим его закешировать с hash, чтобы в рамках одной поставки она закешировалась, а не как сейчас с Nginx по 15 минут.SSR. Было бы круто, чтобы всё приложение рендерилось сразу, а не на клиенте.
Ну и Advanced split chunks — разделить приложение на столько разных кусков, насколько это возможно, чтобы новых скачиваемых скриптов стало меньше
В принципе, это только начало — будем развиваться дальше.
Рекомендуемое:
Подписывайтесь на Телеграм-канал Alfa Digital Jobs — там мы рассказываем про нашу работу (иногда шутки шутим), делимся новостями и полезными советами.
Комментарии (2)
ColdPhoenix
18.12.2024 14:45Хм, то есть это с вашего позволения в мобильном приложении на Android описание входящего перевода на счёт ограничено двумя строками и обрезается?
Теперь чтоб посмотреть номер платёжного поручения приходится копировать текст..(спасибо хоть копируется целиком)
IvanBodhidharma
Это я удачно зашёл. А можно вернуть взад интернет-банк для компьютеров, как было раньше? Чтобы он был без огромных шрифтов, без огромных кнопок и без огромных пустых пространств вокруг и между элементами? Выдайте дизайнеру обычнфй 25" FullHD дисплей, не 13" макбук и не телефон. Телефонисты пусть мобильное приложение используют. На первом скриншоте в статье отлично видно, о чём я.