Переводим 50 приложений на Module Federation и ничего не ломаем

О микрофронтендах и сопутствующей концепции Model Federation на примере большого проекта.

Архитектура микрофронтов до Module Federation

Привет, меня зовут Степан, я главный frontend-разработчик в Альфа-Банке. Проектом, о котором пойдёт речь, занимается наша команда. Только фронтенд-разработчиков в ней 60. Множество команд поддерживают более 50 приложений, приносящих прибыль бизнесу.

У нас ранее были микрофронты, но они были построены не на WMF. Не вдаваясь в документацию, давайте покажу, как всё было устроено, чтобы описать причины переезда. Думаю, будет интересно, учитывая, что проект большой.

Вот так выглядит приложение.

Слева десктопная версия, справа мобильная. 

Слева десктопная версия, справа мобильная. 

На изображении виден remote и host. В host«е снизу тапбар, сверху информация о картах. В него может встроиться абсолютно всё, что угодно. Например, на мобилке видно, что есть дашборд, а справа платежи. Таких приложений 50 и каждое катится отдельно.

Каждое remote-приложение запускается путём определения методов по appID: __mount и __unmount

2e911af4cd7b6420cb1203814ece87af.pngca04783cffabbc2479ab043ba99725ca.png

Этот метод (впоследствии) просто рендерил приложение React.createElement по какой-либо ноде, которая лежала в хосте.

Также в этом приложении у нас был некий Node.js слой (api getAssetsAndConfig), который отдавал статику и конфиг. В основном это были ссылки до статики с js и css, а также фичи с бека.

a6f1a6ffa2f8aaec9ba7157b51b80ca3.png

Эту статику запрашивал host и вставлял её в dom.

d03a94fdd3d209cc822a333b1c166a44.png

Тем самым после загрузки мы брали и вызывали те самые методы __mount и __unmount: __mount вызывался — приложение рендерилось.

71739c1a586a72cfabba772897dee1ac.png

Всё, микрофронты готовы. 

При __unmount мы удаляли те самые скрипты и JS-файлы, тем самым боролись с изоляцией стилей. Конфликт стилей — это больная тема. Но об этом позже.

Что здесь не так?

№1. Нет возможности переиспользовать зависимости.

Из-за этого на некоторых приложениях бандл достигал 1 мб br, что ненормально для одного монолитного приложения, а у нас тут один микрофронт весит столько. А всего их 50. 

№2. Вызов getAssetsAndConfig не давал возможности уменьшить водопад запросов и закешировать запрос до конфига.

Это был BFF-слой, мидловая ручка, к которой мы всегда стучались. Её хотелось бы закешировать в рамках одной раскатки, чтобы вышла одна версия и закешировалась до конца. С нашим подходом так не получится.

0deedeec5f422cb849afe4d8335e8d77.png

№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. Как внедрять?  

Кратко о внедрении

ab5e149a3e5ffda71e0801c5ca82672e.png

Для встраиваемых приложений изменяется структура папок.

b100ee1deab4f3ac1fed6398efc62d25.png

Главные из них — это bootstrap.tsx, remote.tsx и index.ts

Посмотрим на них подробнее.

remote.tsx 

Один из трёх файлов, что отдаётся наружу и встраивается в host. 

Ниже мы как раз говорим: «Вот этот кусок кода будет использоваться снаружи».

2d8f1b6867fec44669a8630a1136df0d.png

Его будет использовать host, для которого мы в Webpack прописываем exposes

a598d2e91860639804fdec27113bc4df.png

Два остальных файла нужны сугубо для локальной разработки, чтобы поднять проект и дописать что-то своё. 

index.ts

Этот файл состоит всего из одной строки, где мы импортим bootstrap-файл. 

bc9b9f9064e8a4c6e446da96dbc853c5.png

Настраиваем Webpack на эти пути. 

5830e473063f5ea5c8eae8782b44179f.png

bootstrap.tsx

А bootstrap.ts, в свою очередь, поднимает приложение локально в standalone режиме, чтобы иметь возможность разрабатывать только свой функционал или прогнать тесты на CI.

3604f1ae65a40458a0dc6e0830e36d27.png

В InitNewclickDevEntrypoint видно, что мы просто рендерим приложение, например, есть утилиты @axe-core/react для юзабилити. В общем, есть всё, чтобы запустить проект индивидуально.

9223545f2271bca8eaa18ed86d10d75c.png

Не слишком ли много кода для приложения?

Очевидно, что столько кода в каждом приложении держать нельзя. Поэтому всё вносится в отдельный пакет, который называется «пресеты».

"devDependencies”: {
	"@alfa-bank/newclick-arui-scripts-presets”: "9.14.1”,

В него скидываются все Webpack конфиги и модули, которые мы хотим шарить. Чтобы перевести на новое приложение, разработчику достаточно обновить @alfa-bank/newclick-arui-scripts-presets и поменять структуру папок по шаблону.

Плагин WMF

Вот как раз и они — модули. Настройки лежат в общей библиотеке, где мы можем настраивать конфиги сразу для всех приложений.

d3da57dd1829a6fe51a880cc6160f836.png

На первой итерации мы шарим только react, react-dom, какие-то роуты — большие библиотеки, которые используются и изменяются достаточно редко.

Путь до ассетов 

Прописываем publicPath.

output: {
    …newConfig.output,
    publicPath: ‘/${name}/assets/’,
},

Это важно, потому что Model Federation записывает этот публичный путь как раз таки в remoteEntry. Он показывает, куда идти до /${name}/assets.

Например, у нас была проблема, что при прошлой архитектуре путь был динамическим. И когда мы стали использовать WMF, то два приложения грузились одновременно и начали конфликтовать. Пришлось отказаться от «динамики».

Кеширование remoteEntry

На первой итерации на Nginx мы прописали максимальный кэш файла в 15 минут, чтобы пользователь мог чувствовать себя комфортно в рамках сессии. 

Но api Webpack позволяет не только захардкодить remoteEntry.js, но и добавить к нему контент хеш, чтобы при последующих загрузках приложение существовало в рамках одного релиза. Зарелизился, прошло неограниченное количество времени, а у пользователя до сих пор в хеше лежит этот файлик.

dee25cb8bdf249ada81de6e143b2ad9b.pngeb380a86bf1226b4334192685ba32808.png

Host

А вот в host мы подгружаем приложение не через плагин, а динамически (чуть дальше расскажу почему). Пока что просто обозначу, что добавляем ModuleFederationPlugin — определяем все те же модули, которые хотим шарить.

d271995bee9a877c6d84ca344fbb015e.png

Хост у нас рендерится на сервере, а remote приложения — на клиенте. 

new NodeFederationPlugin(serverWmnfPluginOptions, {}),
new StreamingTargetPlugin(serverWmnfPluginOptions),
…
export const serverWmfPluginOptions = {
    name,
    library: { type: ‘commonjs-module’ },
};

И если запустить это просто так, то нода будет ругаться: «Ты подсовываешь api, которого у меня нет». Поэтому у нас есть такие два плагина-заглушки, по крайней мере, пока у нас нет серверного рендеринга для MF.

Немного о динамической загрузке

В ней есть масса плюсов.

  • У нас есть детальный доступ до api WMF, мы можем определять скоуп или подгружать чанки. 

  • Нет загрузки всех remoutEntry.js файлов. Это важно, когда приложений очень много. Если прописать все приложения в плагине, то при сборке проекта все файлы загрузятся на старте. А пользователю столько просто не нужно.

  • Самое важное — приложению нет необходимости знать о том, что в нём будет в момент сборки. Конфиг можно подтянуть с мидла (и вообще откуда угодно).

Как встроить приложение

Для этого нам необходим remoteEntry.js файл. В нём хранится вся информация о чанках, пакетах, которые мы можем переиспользовать, куда идти до ассетов и многое другое. 

Из официального примера добавим загрузку этого скрипта в dom. 

e96a33de77fee07fd2c6349ec1da939f.png

После загрузки используем api WMF.

43d835aa5cef3db87dd99b52000c3b14.png

Здесь:  

  • Инициализируем __webpack_init_sharing — это общий скоуп зависимостей.

  • Инициализируем наш контейнер, который как раз таки загружается в remoteEntry.js файле.

  • Инициализируем общие модули в remoteEntry.js файле.

  • Снаружи загружаем чанки, которые нам нужны. 

И получаем модуль. 

После у нас загружается вся необходимая статика, чанки взяли из общего скоупа или они загрузились из приложения.

const Remote = await loadWmfComponent(scope, module);

Можно его просто расценивать как лейзи компонент в host, который мы загрузили. В принципе, именно так мы его и используем. 

Осталось вставить его в рендеринг и всё готово.

React.lazy(memo(RemoteModule.default));

Как работать с роутингом

Если очень кратко, то у нас используется api react-router пятой версии. Мы подписываемся и на host, и на remote приложение: следим за всеми изменениями в них, соответственно.

3000114813ccf348093c7e5dd50a0fd6.png

Как следствие, каждый знает куда он пошёл и зачем, потому что, например, в remote приложении может быть много страниц, а у host есть navbar. Они между собой общаются, и получается та самая магия, когда все друг о друге знают и нет никаких проблем.

Но, конечно, не может быть всё так просто.

«Просто» проблемы и проблемы специфичные

Если про WMF есть доклад на конференции или статья на Хабре, то, скорее всего, там будет блок про ошибки. И самая популярная ошибка, которой очень часто встречается в докладах —  это shared module is not available for eager consumption: когда надо настроить эти два файла для локальной разработки, иначе Webpack не помечает файлы как асинхронные и не может зашарить эти модули.

А есть ошибки, о которых на конференциях не говорят. Просто потому, что они для каждого проекта специфичные. Давайте расскажу о тех, что вылезли у нас.

Split chunks

В проекте у нас реализован сплит чанков для долгого кэширования: разделяем какие-то чанки на группы, и пользователи долго живут в кеше. Достаточно большая тема, но с WMF надо подчеркнуть две главные особенности.

Для нахождения чанка нам в api плагина для expose надо прописать название чанка в момент сборки, чтобы при последующем сплите мы могли его найти. Нигде в документации api WMF про это не написано, что expose можно передать не только как строку, а ещё и как объект с полем name, которым является тот самый entrypoint. Но мы нашли это свойство в плагине.

be95c2252b7d57f1acd69e9452037249.png

Второе: когда проект собирается у нас под капотом у нас два entrypoint: базовый, которые мы прописали в Entry Webpack, и тот, что прописывается в поле expose в плагине MF. Это можно увидеть, если, например, написать кастомный хук и заметить, что он видит, что это отдельный entrypoint. 

Для этого мы прописали вот этот «magic comments» от Webpack, чтобы найти эти чанки и разделить.

f6958172c015fd6908f11da45654ad66.png

А в самом Webpack надо настроить фильтр для всех entry.

00eafe8b3dafbc0a5d8dafd7128d01fb.png

Так мы находим первые два десктопа по entry. 

Это наш грейд: используем базовые библиотеки, чтобы объединить в один чанк, который редко обновляется.

2b18d7f6bcf23ddd6e3786c7fe43ff04.png

css-конфликты

С микрофронтами есть две основные проблемы.

№1. Каждый проект имеет свои стили: кастомные и те, что экспортируются из ui-библиотеки. 

Если не выстроить в правильном порядке загрузку, то стили библиотеки будут переписывать кастомные. Особенно это заметно, когда, например, грузишь два микрофронта и стили библиотек добавляются в конец, перезаписывая стили всех остальных загруженных приложений.

Если использовать большой проект, то api mini-css contract плагина самостоятельно понимает, какие чанки идут впереди ui-ных библиотек. С микрофронтами так не получится.

№2. Версии библиотек разнятся: разные ui-библиотеки, разные отступы, это может всё конфликтовать и куча багов вылезает. 

Нам стало понятно, что стили надо изолировать в рамках приложения, делать их просто уникальными, чтобы при раскатке одного ты не тегал другую команду.

Есть два решения.

Первый вариант изоляции стилей — это Shadow dom. 

Но у него есть свои проблемы: с монтированием стилей, с доступностью общего контекста из него, придумыванием решения по шарингу библиотек со стилями. Ибо если закинуть стили Shadow dom, то они:

  • во-первых, удалятся из него;

  • а во-вторых, нет никакого доступа из другого Shadow dom до него. 

Много проблем. 

Второй вариант — сделать сами классы, всё, что касается стилей, уникальным от проекта к проекту.

Вот этот вариант мы и выбрали и используем api css-loader. Уникальность стилей достигается путем добавления hash приложения в названия классов каждого (класса). 

2786b3b0fa577f72dedf23903043c6f8.png

Но здесь важно, чтобы все стили, которые вы используете, были импортированы как css module, потому что Webpack просто не сможет найти то, что вы хотите поменять.

Для добавления hash используем api css loader для Webpack, а если точнее — функцию getLocalIdent. Через неё мы имеем доступ к конфигурированию каждого класса css модулей. 

Реализация выглядит достаточно сложно (здесь часть).

6fe09c13ec65fa6fc784763d0b87d69e.png

Но можно увидеть, что в конце класса мы добавляем hash значение app name из package.json, тем самым индивидуализируя наши стили. В коде получаем значения ниже, когда ко всем классам добавлен hash f7abfc.

97f428df51d31fcc96258b0d23a2ea4f.png

Но что, если у вас будет библиотека, которая использует css vars или обычный импорт стилей без css module, до которого нет доступа из Webpack?

Для нас такой библиотекой стал swiper, стили которого импортируются как styles.css файл. До неё нет никакого доступа из Webpack. 

Кроме того у нас есть наша ui-библиотека, которая построена на css-переменных, и они точно будут конфликтовать из-за разных версий. Их тоже надо делать уникальными.

Решение — кастомный postcss плагин, который добавляет больше специфичности для таких стилей, как swiper, и изменяет названия переменных с префиксом приложения. 

Кратко: проходимcя по всем переменным и добавляем им префикс.

43f8c240dd59a61eb456477c97e296a3.png

Для css-переменных также: проходимся и добавляем префикс.

371e1db061efe4e7771cd62326af0931.png

Как это выглядит — . newclick-referal-ui. Просто чуть выше ставим ID приложения, тем самым добиваемся уникальности.

16d311c6694d787e5a094bfe4bfa21d6.png

А с переменными видно, что они уникальные от проекта к проекту. 

b44671243b0ca0a2c0f3f91d5dfffed0.png

Подытожим со стилями:

  1. Уникальные стили для каждого приложения решили все проблемы с конфликтами. 

  2. Оставили возможность шарить ui-компоненты через WMF. Это важно, потому что, например, хочется зашарить какую-то кнопочку или сайд панель, а она весит много и используется почти во всех проектах. С Shadow dom не было бы возможности переиспользования, пришлось бы опять что-то костылить. 

  3. Стили всегда остаются в памяти в рамках сессии, что чуть улучшает перформанс. Мы ходим по проектам и они также лежат у нас в памяти.

Вроде всё заработало и можно переезжать?  

Как переезжать?

№1. Весь код в приложениях максимально вынести в отдельные библиотеки.

Мы в принципе так и сделали: всё, что связано с Webpack, с линтером, ушло в библиотеку. Просто достаточно её обновить, сделать какие-то шаблоны и действия, и проект запустится.

№2. Написать правило в линтер с определённым дедлайном, после чего будут валиться билды.

Во время каждого коммита напоминайте людям, что надо переводить, иначе скоро поставки могут быть заблочены. 

№3. Добавить возможность загружать приложение старым и новым способом. 

Сейчас вот об этом и поговорим.

Обратная совместимость

4d1afd465b3819b5d661b22099b33e01.png

В host-ui есть общий конфиг приложений, где мы по флагу WMF смотрим, как контракт поддерживает приложение. Конфиг приложения подтягивается в таком формате:

0b9e2b94099b27e151bb5a0f55fec4e7.png

У каждого приложения есть конфиг с appID и contextRoot, для него добавляется флаг wmf: true. По нему мы как раз и смотрим, какой контракт поддерживает приложение.

В зависимости от контракта идём за remoteEntry. Если он не скачивает скрипты — вызываем старый контракт.

4737dd8c03b7819bf9588e853472a655.png

У данного функционала есть большой минус: при обновлении приложения на новый контракт, надо катить оба и вместе, иначе будут сыпаться ошибки на 404 remoteEntry.js. Хотя всё и будет работать, но в логах останется куча запросов к несуществующей ручке.

6ecd2478bf6efd029e15e36c2a351646.png

Цифры и метрики

Конечно, мы всё замерили до и после, чтобы сравнить.

413b2b8854660341e9d21032d0b6b540.png

На основных приложениях увидели, что прирост бандла сократился на треть, с учетом того, что мы шарим пока только основные библиотеки, которые обновляются редко.

Скорость загрузки приложения также увеличилась — в среднем на 45–50% по дашбордам. Цифры рассчитываем на дистанции, с учётом того, что пользователь ходит по приложению и всё больше зависимостей переиспользуется. Например, libphonenumber нет в каком-то проекте: он подтянулся, пошёл в следующее, но также остаётся в памяти.

Пара примеров того, что получилось. 

Главный экран. 

be535802ba89df4506024ed2358a926b.png

Страница переводов.

3e02d27d5ebe917d41ea5d8b55c7fbb1.png

Это хороший результат, с учётом сокращения бандла и отказа от bmf слоя, а точка входа — это просто js-файл весом в 3 Кб, что немного.

Итого

c62a76751349fea6f12670834a18c6f2.png

Кастомный подход к микрофронтам вызывал проблемы с производительностью: каждое приложение загружалось ощутимо долго. Сейчас ситуация изменилась: приложение стало быстрее загружаться. На скринкасте видно, как изменилась загрузка.

f771bee222ed1ce235481cafb9807f41.gif

Мы выбрали WMF по нескольким причинам:

  • Его очень просто внедрить в систему. Даже не рассматривали ничего другого.

  • У WMF очень много возможностей. 

  • Концепт ощутимо развивается: добавляется в next.js, rspack, server-components. В гайдлайн, что они выстроили, пишут, что хотят добавить, и там много всего интересного.

Ну, а для себя мы подчеркнули главные пути развития микрофронтендной архитектуры:

  • CDN: у нас банк и на CDN завязано много важных доработок, например, на подмену скриптов. С WMF это невозможно сделать. Точнее можно, но плагины, которые это используют, валятся. Приходится дорабатывать.

  • Discovery service. Это как раз-таки про remoteEntry.js файл. Мы хотим его закешировать с hash, чтобы в рамках одной поставки она закешировалась, а не как сейчас с Nginx по 15 минут.

  • SSR. Было бы круто, чтобы всё приложение рендерилось сразу, а не на клиенте. 

  • Ну и Advanced split chunks — разделить приложение на столько разных кусков, насколько это возможно, чтобы новых скачиваемых скриптов стало меньше

В принципе, это только начало — будем развиваться дальше.

Рекомендуемое:

Webpack Module Federation: «официальное» решение в микрофронтендах

Module Federation — это подход, при котором можно разделить приложение на небольшие отдельные модули…

habr.com

Как мы переходили на React-router v6: подводные камни и альтернативы

Мы перешли на шестую версии React-router. Это помогло нам решить несколько проблем, например, опреде…

habr.com

Улучшаем качество кода React-приложения с помощью Compound Components

Я люблю сталкиваться с трудностями. Но с такими, которые можно решить, подумать над интересным решен…

habr.com

Зачем нам Reactive и как его готовить

Привет! Меня зовут Татьяна Руфанова. Сегодня мы будем понимать и принимать Reactive (Реактив). В ст…

habr.com

Архитектурный компромисс в enterprise. Опыт Alfa People. Наш путь сквозь джунгли

Привет, меня зовут Дмитрий Марков. Я архитектор направления в Альфа-Банке. В этой статье мы поговори…

habr.com

​​Подписывайтесь на Телеграм-канал Alfa Digital Jobs — там мы рассказываем про нашу работу (иногда шутки шутим), делимся новостями и полезными советами. 

© Habrahabr.ru