Привет, меня зовут Евгений, я PHP-разработчик в Broniboy. Ища в очередной раз на Хабре нужную информацию, поймал себя на мысли, что здесь маловато статей, на пальцах объясняющих суть и особенности применения микрофронтендов. Поэтому хочу добавить в копилку знаний.
Что такое микрофронтеды
Термин «микрофронтенды» стал входить в обиход с ростом популярности микросервисов. Разработчикам понравилась идея дробления бэкенд-монолита на небольшие, полностью автономные куски кода, и они захотели привнести то же самое во фронтенд-разработку.
Идея микрофронтендов заключается в том, что сайт или веб-приложение представляет собой набор функций, которые разрабатывают отдельные команды. Функции могут иметь совершенно разные цели или решать абсолютно непохожие бизнес-задачи. При этом каждая команда кросс-функциональна и отвечает за полную цепочку реализации функции, от базы данных до UI.
Микрофронтендами могут называть как наборы файлов и конфигураций, так и зависимости, реализующие многократно используемый фрагмент кода, например:
компонент React, Vue, Angular;
общая таблица стилей (например, CSS, SCSS);
клиентская библиотека или утилита;
любое промежуточное ПО (middleware), модули и провайдеры;
целая страница или простой UI-элемент;
небольшая служебная функция или целый микросервис.
Всё это может быть разного уровня сложности и специализированности, однако не стоит делать микрофронтенды слишком общими, чтобы не получить ещё один монолит.
Проблемы, которые решают микрофронтенды
Почему команды приходят к микрофронтендам? Всё начинается с монолита. В одном месте лежит вся кодовая база, весь пользовательский интерфейс, бизнес-логика и работа с базой данных. Периодически возникают God-классы (которые отвечают за слишком многое в проекте); зачастую код сильно связанный, поэтому какие-то части трудно тестировать, а какие-то практически невозможно, ведь пришлось бы слишком сложно или долго мокать данные.
В результате новичку тяжело мысленно охватить проект, запомнить сразу множество процессов. А рядовым разработчикам приходится мириться с долгой сборкой и бороться с описанными выше проблемами. Есть разные методы борьбы (возможно, некоторые из них вы уже успешно применяли).
Возможные подходы
Для начала разделим бэкенд и фронтенд на две независимые друг от друга части. Для этого в монолите создадим API-контроллер, от которого клиентское приложение получает данные и что-то показывает пользователю. Что нам даёт такая схема?
Несомненные преимущества:
Пользовательский интерфейс теперь отделен от бэкенда, клиенту нужно лишь запросить данные и отобразить их.
При этом клиентский код тоже делится на модули, благодаря чему его проще развивать и поддерживать.
У каждого элемента на странице свой API-метод, поэтому разработчикам легче ориентироваться в проекте.
Однако есть и недостатки:
У нас всё ещё огромный бэкенд с кучей источников данных.
Растёт количество клиентских виджетов, из-за чего увеличивается время сборки.
Со временем клиентское приложение тоже становится таким себе монолитом, в котором тяжело разобраться.
Теперь у нас более прозрачное клиентское приложение, однако в бэкенде всё ещё непросто разобраться, потому что его кодовая база мало изменилась. Тогда мы дробим API-методы на микросервисы, которые выносим отдельно от монолита. У них появляется своя история развития, свой конвейер, а в будущем, возможно, и своя команда поддержки и развития. Благодаря дроблению бэкенда на микросервисы мы облегчаем кодовую базу монолита и упрощаем его поддержку, снижаем количество побочных эффектов, упрощаем рефакторинг и снижаем входной порог в проект.
Остается вопрос: а что делать с клиентским кодом и как не допустить разрастания монолита, несмотря на все наши попытки его укротить? Самый популярный и жизнеспособный ответ на сегодня: микрофронтенды.
Ключевые идеи
К ключевым идеям микрофронтендов можно отнести:
Каждая команда может выбирать для себя технологии без оглядки на другие команды. А подробности реализации очень удобно скрывать за пользовательскими элементами, открыв желающим нейтральный интерфейс.
У каждой команды должен быть изолированный код. Не нужно использовать общую среду исполнения, даже если вы применяете одну платформу. Приложения должны быть независимыми и автономными, не учитывающие общее состояние или глобальные переменные.
У каждой команды должен быть свой префикс. Договоритесь о правилах именования в тех случаях, когда не получается соблюдать изоляцию. Для избегания коллизий используйте CSS-пространства имен, события, локальные хранилища и куки.
Лучше использовать встроенные функции браузера, а не свои API. Для организации связи не надо создавать единую систему отправки и прослушивания событий, для этого есть браузерные события. Но если вам очень нужен межкомандный API, то чем он будет проще, тем лучше.
Каждая ваша функция должна нести пользу, даже если по каким-то причинам JavаScript пока не удалось выполнить.
Используйте универсальный рендеринг и прогрессивный рендеринг, чтобы улучшить воспринимаемую производительность.
Если вы будете соблюдать эти условия, то сможете разделить фронтенд-архитектуру на микрофронтенды, за каждый из которых отвечает отдельная команда: создаёт различные версии, тестирует, продумывает способы отображения, занимается развёртыванием и обновлением.
Варианты применения
Сначала опишу кратко, а затем поговорим подробнее про каждый способ:
Страницы и ссылки. Просто размещаем в своей разметке ссылки на страницы-компоненты других команд.
Iframe. Создаем iframe, который рендерит функциональность, принадлежащую другой команде.
Композиция с применением AJAX. Отправляем AJAX-запрос на URL, полученный, например из data-атрибута, и отображаем результат.
Маршрутизация на стороне сервера. Создаем nginx-сервер, в нём прописываем алиасы на адреса компонентов, и в результате избавляемся от необходимости писать полный путь до компонентов. То есть было http://localhost:3002/team/module/component, а стало team/module/component.
Композиция на стороне сервера. Обычно выполняется сервисом, который находится между браузером и фактическими серверами с приложением. Самое большое преимущество метода в том, что мы отдаем пользователю полностью собранную страницу, в которую не нужно догружать компоненты. Это позволяет достичь отличной скорости отрисовки первого контента.
Теневой DOM. Это способ создать свой, изолированный DOM для компонента. Для этого подключаем скрипт, предоставляемый другой командой, который в теневом DOM определяет «точку вставки» компонента.
А теперь подробнее о каждом подходе.
Страницы и ссылки
Этот способ хорошо подходит для тех случаев, когда компонент полностью изолирован и живёт своей жизнью на отдельном URL, почти или совсем не пересекаясь с другой функциональностью приложения. Довольно тривиальный подход, поэтому наиболее распространен.
Iframe
У подхода большой недостаток: если все компоненты будут рендериться через iframe, то это будет съедать огромное количество памяти и страница будет жутко тормозить. Но для каких-то очень узких задач вполне полезный подход.
Однако iframe’ы имеют свою реализацию взаимодействия на основе событий postMessage(), которая позволяет прокидывать в iframe данные, поэтому подход можно использовать для реализации микрофронтендов (хотя это делают редко из-за очевидных недостатков). К тому же подход очень плохо согласуется с SEO.
Композиция с применением AJAX
Здесь в игру вступает JavaScript. Команда А пишет код, который отправляет get-запрос на URL команды Б. Запрос возвращает нам заметку и мы вставляем её в свою страницу. Это неплохой вариант, особенно в связке со следующим, однако без JS-страница не будет работать вообще.
Маршрутизация на стороне сервера
По сути, улучшенная версия предыдущего подхода. Теперь мы поднимаем веб-server, в котором задаем соответствующим командам URL’ы (на картинке показано, как это примерно может выглядеть), куда они будут ходить за компонентами. А дальше всё то же самое: отправляем get-запрос и вставляем разметку. В таком случае только командам, использующим компоненты других команд, нужно поменять URL’ы у себя в коде, остальным ничего делать не надо.
Компонент команды А:
Компонент команды Б:
Композиция на стороне сервера
Чаще всего (а может быть и всегда) этот подход реализуют при помощи директив Nginx Server Side Includes. Разработчики вставляют в нужном месте кода директивы SSI (представлены на рисунке ниже), которые nginx потом заменяет на готовую вёрстку. Есть и другие инструменты для реализации этого подхода, но я их упоминать не буду.
Включаем nginx SSI:
<!--#include virtual="/url/to/include" -->
Конвейер развёртывания:
Теневой DOM
После того, как мы вставили разметку в теневой DOM, браузер выполняет «композицию»: берёт элементы из обычного DOM-дерева и отображает их в соответствующих элементах теневого DOM-дерева. В результате мы получаем именно то, что хотели — компонент, который можно наполнить данными. Чтобы отобразить содержимое, для каждого <slot name="..."> в теневом DOM браузер ищет slot="..." с таким же именем в обычном DOM.
Наилучший вариант
Мы в Broniboy используем лучший с точки зрения производительности и надежности подход — Server Side Composition. Поскольку мы имеем дело с монолитом, нам полезно, что его можно использовать лишь частично. Например, так мы при помощи директив SSI оптимизируем критичные участки в разметке:
Те места, что раньше подгружались AJAX’ом, теперь приходят с сервера композиции целиком. Но теперь мы лучше контролируем все медленные места, они стали предсказуемее, задержка в получении данных минимальна, потому что речь идёт не о загрузке контента клиентом, а об общении сервиса с сервисом. В таком случае мы не зависим от скорости интернета наших клиентов и доставляем им контент так быстро, как только мы вообще способны.
Директивы SSI позволяют оптимизировать критичные участки кода, которые в противном случае пользователю пришлось бы ждать с сервера. Благодаря этой технологии мы можем обеспечить максимально быстрое отображение первого контента для пользователя, что всегда хорошо сказывается на UX и общих показателях сайта. И конечно же, мы получаем все преимущества от повсеместного внедрения микрофронтендов.
Мы решили пока что ограничиться частичным внедрением микрофронтендов, которые будут жить на отдельных URL’ах, получая данны через сессию. Если данных в сессии не хватает, мы просто добираем их из API. Дальше уже работают веб-фреймворки, а при необходимости мы записываем данные в сессию. Не внедряем повсеместно, поскольку пока нет сложностей с масштабированием компонентов и их изоляцией.
Способы общения компонентов
Актуальные сегодня способы коммуникации компонентов:
Parent-Child Communication. Родитель может взаимодействовать с потомком, то есть мы подключаем к странице предоставляемый другой командой скрипт, например, <script src="http://localhost:3002/static/fragment.js" async></script>, в котором создаётся пользовательский HTML-элемент, принимающий данные от родителя с помощью механики custom elements.
Child-Parent Communication. С помощью кастомных событий можно общаться с дочерними компонентами других команд. И таким образом мы можем нативно реагировать на изменения и обрабатывать их.
Fragment to Fragment Communication. Суть та же, что и у подхода Child-Parent Communication, но в данном случае события слушает не родитель, а другой вложенный компонент, который как-то реагирует на эти события реагирует.
Экосистема вокруг микрофронтендов
При активном внедрении микрофронтендов их вскоре становится очень много. Разработчики могут терять уйму времени на межкомандное взаимодействие, тщетно стараясь найти ответственных за тот или иной компонент. Эту проблему можно решить с помощью инструментов вроде Bit (пример его работы на скриншоте ниже). Он позволяет составлять приложения из независимых компонентов. Причём компоненты могут представлять собой композиции из множества других компонентов (допускается любой уровень конкретности), которые в конечном итоге составляют всё приложение. Для этого применяется рабочее пространство, в котором мы регистрируем исходные компоненты, а затем создаём новые и указываем для них пространства имён.
Заключение
Микрофронтенды не так просты, и у них есть свои недостатки. Часто возникают трудности с общими стилями и иконками, критичными для всего проекта. Если мы захотим в мире микрофронтендов поменять брендовый цвет с оранжевого на красный, то нам нужно будет попросить все команды изменить этот цвет у себя в компонентах, а в монолите на эту задачу ушло бы от силы 20 минут. Как бы мы ни старались, но в конце-концов монолит никуда не денется, и не стоит считать микрофронтенды технологией, которая позволит навсегда отказаться от монолита. Нам по прежнему нужна единая точка входа на сайт, например, чтобы получать все необходимые стили. Мы также не сможем отказаться от большинства существующих обработчиков обмена данными.
Микрофронтенды слабо связаны между собой, и их достоинства полностью раскрываются лишь при грамотном планировании. Иначе можно получить коллизии имён в разметке (поскольку она общая для всех), проблему с роутингом (которую позволяет решить монолит), проблемы с обновлением дизайна и т.п. Эту технологию, на мой взгляд, разумнее рассматривать с прицелом на будущее, вынося весь новый код в компоненты, и при необходимости выносить туда же критичную функциональность, которую необходимо отображать как можно быстрее.
Микрофронтенды — однозначное добро, необходимо лишь ясно понимать, что мы хотим достичь, и осознанно выбрать самый эффективный подход. Что вам подойдёт лучше всего, серверная композиция или пара iframe на странице? На эти и другие вопросы нужно отвечать с оглядкой на цели и ресурсы проекта.
Источники:
Комментарии (5)
Schalaeff Автор
29.10.2021 14:26Я чей-то комментарий случайно отклонил, про то, что iframe -это далеко не однозначное добро.. пожалуйста, напиши его снова, я одобрю)
hardtop
Сколько я не читал про микросервисы - всё же это для большого бизнеса с тучей серверов и тучей людей? В мелких проектах избыточно?
Да и не всегда понятно, как делить то? Пусть есть Пользователи с правами доступа, и есть Список задач. Не все пользователи должны видеть все задачи. В случае с классического подходом монолита мы в одном месте проверили Авторизацию пользователя, проверили Права и сделали выборку Задач, сгенерировали и отдали страницу.
Как правильно поступить в микросервисном мире? На странице уже 2 компонента. Один должен дождаться другого? А если компонентов 10?
Schalaeff Автор
По моему мнению и опыту - да. Изначально легче и разумнее (опять же - по моему мнению) начать с монолита, чтобы потом при росте его распиливать и реализовывать компонентность. Почему проще? Потому что таким образом мы решаем основные вопросы связанные с общим роутингом, общими стилями всего проекта и т.п. В общем и целом это банально дает нам понять что именно мы хотим отпилить и каким образом это сделать было бы предпочтительнее. Мне кажется, что без монолита сама первопричина изобретения микрофронтендов теряет смысл.
В проектах, где я видел успешное внедрение микрофронтендов все компоненты общались с бекендом через экшены, поведение которых менялось в зависимости от роли пользователя.
Тривиальное решение в лоб - мы получаем куку с id пользователя, проверяем его роль и отдаем ему на фронт только те задачи, которые ему положено видеть. Собственно, Вы практически это же и предложили, только в моем подходе мы данные возвращаем не странице, а компоненту, вот и все
Про "Один должен дождаться другого? А если компонентов 10?" я немного не понял, извините) Компоненты связаны с бекендом или друг с другом, это да, но зачем ради получения информации им дожидаться друг друга, ведь HTTP2 и webpack module federation решает вопросы ленивой загрузки модулей.
Надеюсь, ответил на все Ваши вопросы)
hardtop
Спасибо за пояснение. А если перейти от микрофронта к микросервисам (маштабирование, все дела). И есть 2 физических сервера Пользователи (с авторизацией, набором прав и пр.) и Задачи (тоже с какими-то параметрами).
Вот при такой ситуации мы загружаем 2 компонента. Задачи ждут инициализации Пользователя, показывая скелет. И только получив данные от Пользователя Задачи достают нужные данные с сервера? Так?
Schalaeff Автор
По идее у Вас же уже есть на бекенде вся информация о пользователе, его роли и задачах, которые ему доступны, если я правильно понял) Так пусть и одни и другие идут в бекенд за данными, получают их оттуда и никого не ждут.
Хотя и вариант который Вы предложили реализовать несложно: у нас на страницу загружается компонент с пользователями. Когда он загрузился (fetch успешен), мы в .then() делаем dispatch() события, которое сигнализирует об успешной инициализации компонента и начинаем загрузку компонента с задачами) В целом, да, тоже ок, до этого просто скелет показываем.. но это вроде плохо вяжется с seo