Всем привет! Меня зовут Виталий, я ведущий frontend-разработчик в KTS.
В этой статье я делюсь опытом проектирования архитектуры frontend-приложения, которое взаимодействует с большим количеством внешних систем. Фронтенд состоит из главного проекта и отдельных модулей — микрофронтендов. Главный проект делаем мы в KTS, а микрофронтенды разрабатывают сторонние команды.
В статье мы рассмотрим следующие аспекты:
Микрофронтенды. Мы попробовали три подхода ко встраиванию модулей. В статье проанализируем преимущества и недостатки каждого. Поделюсь, почему мы переходили от одного способа к другому.
Пятёрочка, что ты?
Два года назад в KTS пришел X5 Retail Group с проектом нового личного кабинета сотрудника Пятёрочки — ЛК2. Через личный кабинет сотрудник может посмотреть зарплату и рабочее расписание, запланировать и согласовать отпуск, запросить справки с места работы, подписать документы электронной подписью и т.д. Руководитель нанимает, увольняет или переводит сотрудника, управляет командой... В общем, это продукт с огромным количеством бизнес-процессов, ролей и доступов.
Много процессов уже автоматизировалось в независимых сервисах, некоторые процессы выполнялись вручную. Поэтому чтобы продукт оправдывал ожидания, работа должна идти быстро. Конечная цель — получить продукт, который объединяет взаимодействие всех систем в одном приложении.
Часто процессы независимы — они не влияют друг на друга, и их можно развивать параллельно. Например, раздел расписания сотрудника можно разрабатывать одновременно с разделом выплат. Это значит, что проект можно разделить между несколькими командами и за счет этого увеличить скорость перехода на новый портал.
Спустя полгода разработки MVP сформировалось ядро проекта и появилась возможность распараллелить работы. Здесь начинается интересное: особенность параллельной разработки ЛК2 заключается в том, что продукт разрабатывается не единой командой и даже не одной компанией, а многими независимыми командами и компаниями. В таком режиме невозможно взаимодействовать в одном репозитории, поскольку это сразу же привело бы к хаосу. Поэтому нужно было придумать удобный механизм независимой разработки многих проектов и их интеграции в базовый проект.
Архитектура ЛК2
Базовый проект ЛК2 разрабатываем мы в KTS. В ЛК2 содержится корневая логика и большая часть бизнес-процессов. И мы же занимаемся подключением модулей других команд.
Зафиксируем требования и принципы, исходя из которых мы проектировали архитектуру:
Проект должен быть в одном визуальном стиле;
Для удобства пользователя проект должен быть SPA;
Авторизация, навигация и др. — это базовая логика ядра проекта. Взаимодействие с ядром должно быть предсказуемым, единообразным и легким для всех команд;
Сторонняя команда должна затратить минимум усилий, чтобы разработать модуль и встроить его в ЛК2.
Проект представляет из себя монорепозиторий, который содержит три пакета:
lk2
— главный пакет, который реализует всю бизнес-логику.lk2
использует остальные пакеты монорепозитория, а также именно в него подключаются сторонние модули.
Следующие два пакета монорепозитория используют все команды, поэтому мы публикуем их в приватном NPM-registry.
@five/uikit
— библиотека UI-компонентов;@five/core
— ядро содержит основные сторы, React-контексты и часть пользовательского интерфейса.
Подробнее о том, как засетапить монорепозиторий, можете почитать в нашей статье.
На рисунке ниже изображена получившаяся схема.
Преимущества монорепозитория следуют из того, что проект состоит из единой кодовой базы:
@five/uikit
и@five/core
часто дорабатываются. Эти пакеты входят в монорепозиторий, поэтому после каждого их изменения не нужно публиковать новую версию. Во время сборкиlk2
будет использован код библиотек не из registry, а из репозитория, поэтому он всегда актуален. Новую версию публикуем только чтобы предоставить изменение сторонним командам.Удобно разрабатывать проект по веткам: и в
@five/uikit
и в@five/core
могут быть специфичные для ветки изменения.Ориентироваться в одном проекте гораздо проще, чем в трёх отдельных проектах.
UI-библиотека @five/uikit
@five/uikit
— это библиотека UI-компонентов. В неё мы выносим все основные компоненты: кнопки, панели, календари и т.д. Компоненты из библиотеки используются во всех микрофронтендах и не завязаны на бизнес-логике.
NPM-пакеты собираем с помощью Rollup. Этот сборщик, в отличие от Webpack, собирает ES-модули и лучше подходит для библиотек. На момент написания статьи в пятом Вебпаке уже появилась сборка ES-модулей в экспериментальном режиме. Когда эта функция станет стабильной, мы откажемся от Rollup в пользу Webpack, чтобы сборки на проекте выполнялись одним инструментом.
Для документирования используем Storybook.
Подробнее о том, как собрать свою UI-библиотеку, расскажу в одной из следующих статей.
Ядро проекта @five/core
@five/core
— это ядро проекта. Его использует lk2
и каждый микрофронтенд.
Сторы
В первую очередь core
нужен для того, чтобы у всех встраиваемых модулей был общий контекст, который содержит данные о сессии и о пользователе. Поэтому core
включает в себя базовые Redux-сторы.
userStore
содержит данные пользователя. Этот стор испускает глобальные Redux-экшены. Например, экшенUSER_NOT_AUTHORIZED
срабатывает, когда пользователь разлогинился или запрос к api завершился со статусом 401. Называем эти экшены глобальными, т.к. они предназначены всему приложению, и их должен обработать каждый стор. Например, перетереть или перезагрузить данные.Все запросы к API проходят через
apiStore
. В нём обрабатываются глобальные ошибки:в случае ответа со статусом 401 показываем экран авторизации;
в случае некоторых внутренних ошибок сервера показываем сообщение.
Именно благодаря тому, что все запросы проходят через
apiStore
, мы можем обработать ошибки на любой запрос и инициировать глобальные экшены, например,USER_NOT_AUTHORIZED
. ВapiStore
нет своего Redux-стейта, он содержит только thunk-экшены и вспомогательные утилиты.
navigationStore
. Как писал выше, личным кабинетом пользуются сотрудники с разными ролями. Роль влияет на доступность раздела. У сотрудника магазина есть раздел с графиком работы, а у офисного сотрудника этого раздела нет, потому что офисный сотрудник работает с понедельника по пятницу. Информацию о доступных разделах заполняет наш менеджер в панели администратора.В
navigationStore
хранится конфиг пользователя, который вычисляется на основе его данных. Например, можем показать баннер только в определенном регионе или показать кнопку для сотрудников определенной должности.Итого:
navigationStore
хранит информацию о доступах пользователя в зависимости от параметров, с которыми открыт ЛК2:сессия пользователя;
платформа, с которой открыт ЛК2 (десктопный браузер, мобильный браузер или WebView мобильного приложения).
Корневой React-компонент X5LkApp
@five/core
экспортирует React-компонент X5LkApp
. И lk2
, и микрофронтенды должны иметь доступ к данным из ядра. Для этого их нужно обернуть в X5LkApp
. X5LkApp
содержит глобальный контекст приложения и часть пользовательского интерфейса.
Глобальный контекст
Для взаимодействия микрофронтендов и главного приложения нужен общий контекст. Этот контекст задаётся несколькими React-провайдерами. Провайдеры содержатся в @five/core
, поэтому данные контекста одинаково доступны и в lk2
и во встраиваемых модулях. Рассмотрим некоторые контексты:
Глобальный Redux-стор. Есть два способа, которыми модуль может взаимодействовать с глобальным стором:
Через контекст
X5UserContext
. В таком случае доступен только ограниченный набор данных и экшенов.Через inject в глобальный стор. Тогда можно обращаться ко всем Redux-сторам проекта.
Второй вариант менее безопасен и используется в редких случаях. Например, его используем мы в
lk2
, т.к. на нашей стороне полностью контролируется взаимодействие с данными.React-роутер.
ConnectToMobX
— коннектор глобальных сторов из Redux к MobX. Да, на проекте есть оба стейт-менеджера. Сначала мы использовали только Redux, но затем попробовали MobX, и жить стало веселее: кода становится меньше, а задачи закрываются быстрее. Сейчас мы постепенно переписываем сторы c Redux на MobX.StatisticsProvider
— провайдер контекста для сбора статистики.
Всего есть 7 таких провайдеров.
Пользовательский интерфейс X5LkApp
Страница авторизации.
Разметка авторизованного пользователя. Она включает в себя боковое меню и шапку с информацией о текущем пользователе.
Обёртка X5LkApp
используется и в lk2
и в микрофронтендах, поэтому разработка нового раздела выглядит одинаково и для нас, и для сторонних команд.
Рассмотрим использование X5LkApp
на примере.
В стороннем модуле:
Корневой компонент микрофронтенда:
// externalModule/src/ExternalModuleApp.tsx
import { X5LkApp } from '@five/core';
import ExternalModuleContent from './ExternalModuleContent';
const ExternalModuleApp = () => (
<X5LkApp>
<ExternalModuleContent />
</X5LkApp>
);
export default ExternalModuleApp;
Компонент с бизнес-логикой:
// externalModule/src/ExternalModuleContent.tsx
import { useX5Data } from '@five/core';
// Используем UI-компоненты из библиотеки
import { Typo, UserCard } from '@five/uikit';
const ExternalModuleApp = () => {
// ExternalModuleApp обёрнут в контекст X5LkApp,
// поэтому внутри доступен контекст
const { user } = useX5Data();
return (
<>
<Typo>Привет из стороннего модуля</Typo>
<UserCard user={user} />
</>
);
};
export default ExternalModuleApp;
В lk2:
// five/packages/lk2/src/Root.tsx
import { useX5Data } from '@five/core';
import SomePage from './pages/Page';
// В этом компоненте импортируется модуль
import ExternalComponentPage from './pages/ExernalComponentPage';
export const Root = () => (
// Контент lk2 тоже оборачиватеся в контекст из @five/core
<X5LkApp>
<Switch>
<Route path="/some-page" component={SomePage} />
<Route path="/external-module-page" component={ExternalComponentPage} />
</Switch>
</X5LkApp>
);
Вспомогательные компоненты
@five/core
экспортирует вспомогательные компоненты. Они упрощают работу с разметкой. Например:
Layout.LayoutContent
— обёртка добавляет отступы, заголовок страницы и кнопку "Назад".Также среди вспомогательных компонентов есть компоненты, которые нужны всем командам, но они сильно завязаны на бизнес-логику, поэтому их нельзя вынести в
@five/uikit
, в котором содержатся только "глупые" компоненты.Пример — панель для выбора сотрудника.
В итоге получается такая схема взаимодействия @five/core
, lk2
и микрофронтендов. Стрелки показывают использование одних компонентов другими.
Как мы подключаем микрофронтенды
О микрофронтендах много говорят, в нашем случае этот подход полностью оправдан и необходим:
Команды разрабатывают модули изолированно и не влияют друг на друга.
Команды появляются и уходят, конечный результат их работы — отдельный модуль, который легко доработать, переместить или удалить, но команда не может внести изменения в архитектуру системы — преимущество с точки зрения надёжности и безопасности.
Команды придерживаются отличающихся практик и требований к написанию, качеству и стилю кода, у команд по-разному построены процессы. Изолированность позволяет каждой команде работать в привычном режиме, что ускоряет разработку и внедрение.
Бизнес-процессы в некоторых модулях очень сложны, поэтому их разрабатывают специальные команды.
Мы пробовали три варианта подключения модулей. Ниже проанализирую преимущества и недостатки каждого из них, а также поделюсь, почему мы переходили от одного к другому.
iframe
В самом начале ещё не было пакета @five/core
, но необходимость во встраивании модулей уже была. iframe
стал первым способом, с помощью которого мы встраивали модули.
Перед нами стояла задача — подключить модуль в кратчайшие сроки. В тех условиях iframe
показался самым простым вариантом.
В React-приложении достаточно сделать страничку, в которой растянуть iframe
и вставить в него ссылку на модуль. С учётом того, что у нас уже был администрируемый navigationStore, это сделать особенно просто.
Модуль использует API бэкенда ЛК2. Но т.к. содержимое iframe
и родительская страница изолированы друг от друга, модуль должен пройти авторизацию в ЛК2. Для этого на бэкенде нужен соответствующий механизм. Его разработка означает дополнительные расходы, но в дальнейшим и для других способов встраивания пригодится авторизация, поэтому это не недостаток, а необходимость.
Механизм авторизации должен возвращать модулю токен, далее с этим токеном модуль ходит в наш API. В значительной своей части бэкенд представляет из себя proxy к уже существующим бэкенд-сервисам. И чаще всего модуль создаёт для него интерфейс. Но в этом бэкенд-сервисе тоже нужна авторизация, поэтому вне зависимости от того, использует ли модуль данные из бэкенда ЛК2, он должен авторизоваться, чтобы получить токен к целевому бэкенд-сервису.
Изолированность iframe
повышает стабильность фронтенда: если внутри модуля всё сломается, и страница упадёт, это никак не повлияет на работу остального проекта. Но есть и обратная сторона медали: сильная изолированность усложняет взаимодействие модуля с приложением:
Родительская страница общается с iframe с помощью postMessage. Это значит, что, нужно прорабатывать протокол сообщений, реализовывать механизм асинхронного взаимодействия модуля и ядра. Это явно менее удобно, чем, например, React-пропсы.
Поскольку встраиваемый модуль находится вне контекста ядра, в него сложно бесшовно интегрировать роутинг, а также другие контексты приложения.
Возрастает интенсивность использования API: данные, уже полученные в
lk2
, должны повторно запрашиваться вiframe
. Можно, конечно, синхронизировать данные черезpostMessage
, но кажется, что это ещё сложнее.Сложно поддерживать в актуальном состоянии внешний вид и корневой функционал: при обновлении
@five/uikit
команда модуля должна актуализировать версию пакета. Может появиться расхождение.
В случае встраивания через iframe
модуль — это отдельный сервис. Команда менее ограничена стеком корневого модуля, она может настроить собственный CI/CD. Это плюс, поскольку даёт большую свободу действий. Но это и минус, так как команде нужны дополнительные ресурсы:
нужно выделить сервера, на которых будет хоститься сервис;
чтобы разложить сервис, нужно затратить усилия на настройку инфраструктуры.
Важный плюс встраивания через iframe
— этот подход позволяет разрабатывать и обновлять модули без привлечения фронтендеров команды ЛК2: для встраивания достаточно в панели администратора добавить ещё один раздел со ссылкой, и он автоматически появится в проекте. Так же и с обновлением: команда модуля просто раскладывает новую версию приложения и эти изменения оперативно, независимо от команды ЛК2, оказываются в продакшене. Поскольку количество модулей постоянно растёт, и обновляются они довольно часто, эта особенность очень полезна. Следует учесть, что в данном случае ответственность и контроль за работоспособностью модуля полностью лежит на команде, разрабатывающей его.
Сложности начинаются, когда модуль должен предоставлять более одного компонента. Приведу конкретный пример: в личном кабинете есть главная страница, на которой расположены виджеты некоторых разделов. На рисунке выше можно видеть виджеты разделов "Деньги", "График работы" и "Задачи". Если модуль встраивается через iframe
, нужно продумывать дополнительные механизмы экспортирования нескольких компонентов: виджета и основной страницы модуля. Это можно сделать, например, через поддомены или с помощью параметров пути, но в любом случае это не выглядит удобно.
Итоги встраивания через iframe
Преимущества:
Относительная простота минимальной реализации;
Изолированность, а следовательно, надежность;
Свобода выбора технологий;
Независимость обновления и добавления модуля.
Недостатки:
Сложное взаимодействие
lk2
и встраиваемого модуля;Отсутствие общего контекста, например, для роутинга;
В пакете требуется поддерживать актуальную версию
@five/uikit
;Изолированность может привести к потере взаимозаменяемости микрофронтендов;
Для разработки модуля требуются дополнительные ресурсы:
Инфраструктура prod / test;
Компетенции;
Время на настройку CI/CD.
Сложно предоставлять сразу несколько компонентов из пакета.
В итоге недостатки перевесили преимущества, и мы перешли к следующему подходу, успев встроить с помощью iframe
только один небольшой модуль.
Дальше интересней!
NPM-пакеты
Следующим шагом мы решили подключать микрофронтенды как NPM-пакеты. Этому способствовало то, что у нас уже был @five/uikit
, оставалось только добавить @five/core
.
У подключения через NPM-пакеты есть важные преимущества:
Простота первой настройки — грубый вариант: копируем конфиги из уже существующего пакета и всё работает!
Не нужны дополнительные DevOps-ресурсы.
Технически версия NPM-пакета — это просто архив собранного проекта, лежащий в NPM-registry, а публикация очередной версии — это просто вызов команды yarn publish
. В итоге NPM-пакет попадает в сборку ЛК2. Это значит, что в отличие от подхода с iframe
, не нужны сервера, которые отдавали бы статику модуля. Более того, очень просто настроить пайплайн, который собирал бы модуль и публиковал новую версию.
Не дублируется код библиотек, используемых сразу в нескольких пакетах.
Скрытый текст
В package.json
есть три секции с зависимостями. Рассмотрим на примере модуля @five/someModule
, который использует react
и собирается сборщиком rollup
.
{
"name": "@five/someModule",
"version": "1.0.0",
"dependencies": {
"react": "17.0.1"
},
"devDependencies": {
"rollup": "2.0.4"
},
"peerDependencies": {
"react": ">=17"
}
}
dependencies
Зависимости из dependencies
устанавливаются транзитивно при установке пакета.
# Потянет за собой зависимость react,
# т.к. она указана в dependencies
yarn add @five/someModule
devDependencies
Зависимости из данной секции не устанавливаются транзитивно. Это значит, что в devDependnecies
можно указать rollup
и при установке модуля этот пакет установлен не будет.
# Rollup установлен не будет, т.к. он указан в devDependencies
yarn add @five/someModule
peerDependencies
Данная секция нужна для того, чтобы декларативно указать пакеты и их версии, которые нужны для функционирования устанавливаемой библиотеки. Если пакет react
укажем в peerDependencies
, то при установке библиотеки увидим сообщение с требованием установить React.
# Увидим сообщение с требованием установить React
yarn add @five/someModule
В нашем подходе все пакеты указываем в секции devDependecies
, а dependencies
пуст. Все библиотеки, которые работают на клиенте (@five/core
, @five/uikit
, react
, mobx
и другие), помимо devDependecies
указаны и в peerDependecies
. Уже внутри пакета lk2
мы можем явно контролировать, от каких пакетов зависят встраиваемые модули и в каких версиях эти пакеты нужны. Ничего не подтянется автоматически без нашего контроля.
В настройках Rollup используем плагин rollup-plugin-peer-deps-external
. Этот плагин исключает из бандла модуля код библиотек, указанных в peerDependecies
. В итоге используется только те библиотеки, которые установлены в lk2
. Это положительно влияет на итоговый размер бандла.
Версии @five/core
и @five/uikit
всегда актуальны, даже если команда не успела актуализировать версию в своём модуле. Поскольку в бандл микрофронтенда не попадают @five/core
и @five/uikit
, эти зависимости берутся из пакета lk2
. А т.к. проект организован в виде монорепозитория, то код библиотек берётся из текущего проекта, а не из NPM-registry.
Из модуля легко экспортировать сразу несколько компонентов.
Использование нескольких компонентов встраиваемого модуля выглядит гораздо проще, чем в случае с iframe
:
import { SomePage, SomeWidget } from '@five/someModule';
Удобное взаимодействие с модуля с ЛК2.
Модуль экспортирует обычные React-компоненты. Это значит, что взаимодействие с ним позволяет использовать все функции из React: общий контекст для роутинга, авторизации, статистики, данных пользователя. В самом простом варианте, мы можем просто передать в компонент пропсы! Как мало нужно для удобного взаимодействия)
Большинство команд разрабатывает модуль с использованием Typescript, что даёт множество преимуществ при разработке модуля. Но для нас это важно тем, что при подключении пакета мы можем просто посмотреть его интерфейс. Это сильно упрощает взаимодействие команд и повышает надёжность совместной работы.
Требования к единому стеку повышают взаимозаменяемость команд.
Единая точка ответственности.
В отличие от варианта с iframe
, команда ЛК2 полностью контролирует процесс подключения модуля, а также его финальную работу. Мы оборачиваем модуль в Error Boundary. Если пайплайн с пакетом упадёт, то сломанный модуль не попадёт в продакшен. Более того, перед релизом мы можем просто протестировать работоспособность модуля.
Недостатки подключения с помощью NPM-пакетов
Проблемы, которые описаны ниже, заставили задумываться о новом варианте встраивания:
Длинная цепочка попадания модуля в продакшен.
Процесс обновления модуля выглядит следующим образом:
Команда модуля публикует новую версию и сообщает об этом нашему менеджеру;
Менеджер ЛК2 заводит для этого задачу на разработчика;
Разработчик делает отдельную ветку с новой версией пакета, раскладывает ветку для тестирования;
Проверяем, что всё собралось, ветка разложилась, пакет работает;
Ждём тестирования и одобрения менеджеров;
Вливаем ветку в
dev
, ждём релиза (или стартуем релиз только ради новой версии);Вливаем
dev
вmaster
и заводим релизную ветку;Публикуем релиз.
На всех этапах, где что-то куда-то вливается, ждём пайплайн, разработчик в это время делает другие задачи (или пьёт чай, потому что переключать контекст работы не очень продуктивно).
А если учесть, что модулей много, их количество постоянно возрастает, и работа ведётся довольно интенсивно, это приводит к тому, что много ресурсов команды ЛК2 уходит только на обновление версий микрофронтендов.
yarn integrity check failed
Слова, которые не греют мне душу... Эта ошибка возникает, если сделать yarn publish
без поднятия версии пакета в package.json
. При установке пакета в файл yarn.lock
попадает хэш от содержимого пакета. Этот хэш специфичен для версии пакета. Если опубликовать версию без поднятия цифры в package.json
, пайплайн, использующий данную версию пакета, упадёт. А если конфликтный номер версии уже в релизе, то упадут вообще все сборки, т.к. они опережают релиз. Работа стоит, программисты модуля заняты публикацией новой версией, а программисты ЛК2 заняты новым релизом и актуализацией версий. Звучит неприятно и глупо, но это человеческий фактор и пару раз такая ситуация происходила.
Код модуля в конечном счете попадает в общую сборку с ЛК2.
Есть вероятность, что ошибка в модуле повлияет на весь проект. Но я бы не сказал, что это существенный недостаток. Проблема теоретически существует, но мы с ней ни разу не столкнулись. Всё-таки профессионализм команд, разрабатывающих модули, а также Error Boundary делают своё дело.
Итоги подключения через NPM-пакеты
С технической точки зрения этот подход очень хорош: он удобен и надёжен и для разработки, и для встраивания. Ключевым его недостатком оказалось сильное растягивание процессов во времени. Для обновления приходится тратить ресурсы команды ЛК2, от чего страдает эффективность нашей работы.
Преимущества:
Простота первой настройки;
Не нужны дополнительные DevOps-ресурсы;
Не дублируется код библиотек;
Версии
@five/core
и@five/uikit
всегда актуальны;Из модуля легко экспортировать сразу несколько компонентов;
Удобное взаимодействие с модуля с ЛК2.
Недостатки:
Длинная цепочка попадания модуля в продакшен;
yarn integrity check failed;
Возможно влияние модуля на ЛК2 и его состояние.
Дальше интересней! Следующий способ, на мой взгляд, обладает большей частью преимуществ подключения с помощью NPM-пакетов, и при этом он позволяет обновлять модули независимо от команды ЛК2 как с iframe
.
Webpack Module Federation
Это актуальный метод, с помощью которого мы встраиваем микрофронтенды.
Технология позволяет подключить модули не во время сборки проекта, а подтягивать их динамически, когда пользователь открыл вкладку браузера. Эти модули хранятся в отдельном репозитории, собираются независимо, их статика предоставляется по фиксированному URL.
Данный способ появился недавно в пятом Вебпаке. Ниже опишу его принцип. Если вы уже знакомы с Module Federation, можете пропустить пояснение.
Скрытый текст
Представим, что есть React-компонент ChildComponent
, который мы хотим использовать у себя в проекте. Код данного компонента расположен в отдельном React-приложении child_project
в отдельном репозитории child_repo
.
Проект собирается и его индекс-файл находится по фиксированному пути:
https://modules.x5.ru/static/child_project/child_project.js
Чтобы использовать компонент ChildComponent
в проекте lk2
, нужно сначала доработать дочерний проект. Для этого добавим Webpack-плагин со следующими параметрами:
// child_project/wepback.config.babel.ts
new ModuleFederationPlugin({
// берём имя из package.json "child_project"
name,
// данный бандл собирается как библиотека с именем "child_project".
// наружу предоставляется переменная, поэтому указываем type: 'var'
library: { type: 'var', name },
// указываем название index-файла: 'child_project.js'
filename: `${name}.js`,
exposes: {
// Указываем, что какой файл экспортировать из модуля по пути
// ./ChildComponent
'./ChildComponent': './src/ChildComponent'
},
// Здесь указываем разделяемые зависимости между текущим модулем
// и тем, в который будет встраиваться данный компонент.
// Аналог того, что указано в peerDependecies в случае с NPM-пакетом
shared: { /* ... */ }
})
Дочерний проект настроен! Теперь добавляем такой же плагин в lk2
, но уже с другими параметрами:
// five/packages/lk2/webpack.babel.config.ts
new ModuleFederationPlugin({
name: '@five/lk2',
remotes: {
// ключ — это алиас, по которому будем импортировать модуль
'@five/childModule':
// формат: <name>@<url>
'child_module@https://modules.x5.ru/static/child_project/child_project.js'
},
shared: { /* То же, что и в child_project */}
})
Далее нужно добавить скрипт со ссылкой на дочерний модуль в шаблон будущего index.html
ЛК2
<!-- five/packages/lk2/src/index.ejs -->
<script src="https://modules.x5.ru/static/child_project/child_project.js>"></script>
И после этого можем импортировать компонент внутри lk2
, как обычный React-компонент:
// five/packages/lk2/src/modules/ChildComponentWrapper.tsx
import { Loader } from '@five/uikit';
import * as React from 'react';
import SentryErrorBoundary from 'components/SentryErrorBoundary';
// Импортируем компонент с помощью React.lazy
const LazyComponent = React.lazy('@five/childModule/ChildComponent');
type Props = {/* ... */};
// Оборачваем компонент в Error Boundary
const WrappedComponent: React.FC<Props> = (props: Props) => (
<SentryErrorBoundary>
<React.Suspense fallback={<Loader />}>
<LazyComponent {...props} />
</React.Suspense>
</SentryErrorBoundary>
);
// Экспотрируем финальный компонент
export default WrappedComponent;
Готово! Компонент разрабатывается в удалённом репозитории, хранится так же удаленно, а мы взаимодействует с ним максимально нативно!
Безусловно, это довольно поверхностный пример, но он наглядно демонстрирует, что такое федерация модулей, и как её использовать. В одной из следующих статей я подробно расскажу, как настроить Module Federation, а также какие подводные камни и тонкости могут быть в данном методе. Эта технология очень свежа, поэтому есть много моментов, которые либо противоречат документации, либо выясняются только экспериментальным путём.
Что нам дало подключение через Module Federation
Этот подход объединяет преимущества встраивания через iframe и через NPM-пакеты.
Независимость разработки и обновления.
Для обновления модуля команда должна собрать проект и разложить статику таким образом, чтобы она была доступна по заранее зафиксированному пути. При этом команда ЛК2 делать ничего не должна.
Возникает два вопроса:
Куда складывать статику модуля? Личный кабинет ранее уже использовал хранилище S3 для некоторой статики. Оказалось, что S3 также очень удобно использовать и для хранения статики модулей. Тогда в пайплайне сборки модуля его деплой заключается в простом копировании файлов по фиксированному пути S3:
aws s3 cp /dist s3://$BUCKET_NAME/$PACKAGE_NAME \ --recursive \ --endpoint-url $ENDPOINT_URL \ --acl public-read
Обратите внимание, что при формировании пути используется
$PACKAGE_NAME
— это имя проекта изpackage.json
. Фактически, это единственный уникальный параметр, используемый при сборке модуля.Это удобно по двум причинам:
Для того, чтобы встроить новый модуль в
lk2
, нам нужно знать лишьname
изpackage.json
и название экспортируемого React-компонента. Весь остальной путь к модулю фиксирован.Команда, которая разрабатывает модуль, использует шаблонный проект. Фактически ей вообще не нужно ничего делать для настройки деплоя.
Что делать с кешированием модуля?
Кеширование нужно, чтобы клиент загружал статику (js, стили и т.д.) не при каждом открытии сайта, а только если эта статика изменяется, т.е. если сделали новый релиз. Обычно механизм кеширования выглядит так:
В названия статических файлов добавляется хеш от их содержимого:
main.js
→main.[contenthash].js
Добавляем статике HTTP-заголовок
cache-control
с долгим временем жизни.
Клиент загрузит файл один раз и далее будет брать его из кеша, пока не поменяется ссылка на него, т.е. пока не изменится его содержимое, а следовательно, и хэш в названии.
В случае с федерацией модулей подход с добавлением хэша не сработает, т.к. ссылка на файл зафиксирована. Эта проблема была описана ещё в feature request с Module Federation. Решается она добавлением кеширующего HTTP-заголовка ETag — это такой же хэш, но он добавляется не сборщиком в имя файла, а сервером статики в момент отдачи файла клиенту. В нашем случае ничего не пришлось дорабатывать, поскольку данный заголовок автоматически проставляется сервером S3, в котором мы храним всю статику модулей.
Удобно экспортировать сразу несколько компонентов.
Для этого в секции exposes
просто указываем несколько полей:
// child_module/webpack.config.babel.ts
exposes: {
'./Page': './src/Page',
'./Wiget': './src/Widget'
},
Модули подключаются как обычные React-компоненты.
Можно передать пропсы
Есть доступ к общему контексту приложения
Код модулей подгружается динамически.
Это положительно влияет на размер бандла и, следовательно, на скорость первоначальной загрузки.
Модули собираются в отдельных репозиториях.
lk2
собирается быстрее, потому что сборщику не приходится обрабатывать код модулей. Соответственно, и пайплайны проходят быстрее.
Легко засетапить новый модуль с использованием шаблонного проекта.
Сейчас для сетапа нового модуля достаточно сделать fork от шаблонного проекта. Нужно сделать примерно 0 дополнительных настроек. Каждый раз делать fork немного костыльно: после обновления шаблонного проекта нужно вручную обновлять каждый модуль. Поэтому в планах внести шаблонный проект в монорепозиторий и оформить его в виде NPM-библиотеки. Получится что-то вроде create-react-app
, только create-x5-app
.
В процессе загрузки общие модули грузятся один раз.
ModuleFederationPlugin
позволяет указать в shared
общие пакеты для нескольких модулей. Например, в модуле A React версии 17.0.1, а в модуле B — 17.0.2.
При сборке модуля A react
попадает в отдельный чанк. Аналогично при сборке модуля B react
тоже попадает в отельный чанк.
Далее уже когда открывается вкладка в браузере, плагин Вебпака видит, что на текущей странице используется один и тот же пакет разных версий в разных модулях. Загружен будет чанк из того модуля, в котором версия пакета выше.
Обратите внимание, что в данном случае не важно, какой из модулей A или B является родительским, а какой дочерним. Такое поведение можно изменять, с чем связано несколько интересных моментов, о которых расскажу в статье про Module Federation. Но в общем случае разделяемые пакеты позволяют уменьшить размер чанков и грузить только нужное.
Конечно, у подключения с помощью Module Federation есть и недостатки:
Основной недостаток вытекает из того, что это свежая технология. Первая настройка заняла много времени. В основном проблемы возникали из-за того, что документация местами не соответствовала реальности, местами приходилось открывать на Гитхабе тайпинги Вебпак-плагинов, чтобы понять, что же на самом деле туда можно передать. Многие тонкости выяснялись экспериментально.
Нужно где-то хранить статику.
Как и писал выше, в нашем случае проблема оказалась несущественной, но забывать о ней не стоит. На некоторых проектах она может привести к дополнительным работам, а следовательно, и затратам.
Ошибка в модуле может повлиять на весь проект.
Конечно, каждый модуль оборачивается в Error Boundary, он поможет отловить исключения, но есть проблемы, от которых компонент-предохранитель обезопасить не сможет.
Пример: ваш проект должен открываться в IE11. В конфиге Babel допущена ошибка и проект собирается в ES6. Открываем сайт ЛК2, подгружается модуль, и страница падает: в код попала стрелочная функция, а для IE это синтаксическая ошибка. Подобные ошибки не могут быть обработаны Error Boundary.
В нашем случае проблема решается тем, что в шаблонном проекте все эти тонкости учтены, а после оформления шаблонного проекта в виде NPM-пакета вероятность такой проблемы станет ещё ниже. Тогда же можно будет добавить этапы пайплайна с проверками, но в любом случае это ведет к дополнительным работам.
Так же, как и в случае с NPM-пакетом, модуль может повлиять на данные приложения.
Сложности с типизацией.
После установки NPM-пакета в node_modules/some-package
оказываются *.d.ts
файлы, в которых описаны типы данного пакета. В случае подключения через Module Federation в нашем проекте нет файлов, описывающих типы модуля: есть только ссылка на модуль. В случае подключения модулей в проект это не существенно, т.к. модуль взаимодействует с окружением через контексты из пакета @five/core
, а в ЛК2 микрофронтенд подключается как React-компонент с парой пропсов:
baseRoute
— базовый путь для относительного React-роутингаuser
— объект с информацией о пользователе.
Эта проблема станет существенной в случае, если вы решите, например, предоставлять через Module Federation библиотеку UI-компонентов. UI-библиотеки экспортируют много "глупых" компонентов, интерфейс которых должен быть заранее известен, поэтому без подробной декларации типов не обойтись. На мой взгляд, по этой причине Module Federation пока не очень хорошо подходит для таких целей.
Выводы по Module Federation
Коротко: продолжаем использовать.
У данного подхода есть и преимущества, и недостатки, но на данный момент он кажется самым удобным и оптимальным. На мой взгляд, текущие проблемы решаются усовершенствованием механизма подключения и не требуют чего-то принципиально нового. За полгода использования Module Federation сэкономил много времени разработчиков и менеджеров, а это значит, что крайне мала вероятность того, что в скором времени мы от него откажемся.
Преимущества:
Удобно экспортировать сразу несколько компонентов;
Модули подключаются как обычные React-компоненты;
Легко засетапить новый модуль с использованием шаблона;
В процессе загрузки общие модули грузятся один раз.
Недостатки:
Очень свежая технология;
Нужно где-то хранить статику;
Ошибка в модуле может повлиять на весь проект и на его состояние;
Сложности с типизацией.
Встраиваются не только в нас, но и мы!
На самом деле, ЛК2 — не самый верхний слой в этой матрёшке. Существует мобильное приложение для сотрудников Пятёрочки. Мобильное приложение реализует логику авторизации и несколько специфичных функций. Приложение открывает WebView, в котором открывается сайт ЛК2. Все основные бизнес-процессы реализованы на нашей стороне.
Взаимодействие ЛК2 с мобильным приложением базируется на следующих моментах:
При встраивании через WebView в
window
появляется специальный объектMobileApp
. Через него@five/core
(а следовательно, иlk2
) узнает, что сайт открыт в мобильном приложении и активирует специфичные функции. Например, изменяется внешний вид навигации. Также объектMobileApp
нужен для передачи информации между ЛК2 и мобильным приложением.Выше писал про
navigationStore
. Повторю: этот стор хранит информацию о различных доступах пользователя в зависимости от параметров, с которыми открыт ЛК2. Платформа — один из таких параметров. Он может принимать следующие значения:web
— ЛК2 открыт в десктопном браузере;mobile
— ЛК2 открыт в мобильном браузере;android
— ЛК2 открыт в Android-приложении;ios
— ЛК2 открыт в iOS-приложении.
Вид приложения тоже влияет на то, какие разделы доступны пользователю.
Заключение
В итоге имеем архитектуру как на картинке:
Мобильное приложение открывает
lk2
в WebView;lk2
использует@five/core
и@five/uikit
, а также микрофронтенды;Каждый модуль создается на основе шаблонного проекта и также использует
@five/core
и@five/uikit
.
Надеюсь, вы узнали что-то новое, и эта статья поможет вам эффективнее проектировать подобные системы.
Пожалуйста, делитесь в комментариях, какими способами вы решали похожие задачи, а также предлагайте улучшения, если вы заметили пробелы или неточности в моём варианте. Спасибо)
В следующих статьях уже с технической точки зрения рассмотрю:
Проектирование современной UI-библиотеки;
Проектирование
@five/core
;Создание шаблонного проекта наподобие create-react-app;
Тонкости использования Webpack Module Federation.
Другие статьи про frontend для начинающих:
Как работают браузеры: навигация и получение данных, парсинг и выполнение JS, деревья спец возможностей и рендеринга
Другие статьи про frontend для продвинутых: