Методы, стратегии и рецепты для создания современного веб-приложения несколькими командами, которые могут работать независимо.
Что такое Micro Frontends?
Термин Micro Frontends впервые появился в ThoughtWorks Technology Radar в конце 2016 года. Эта техника распространяет концепции микросервисов на мир фронтерд разработки. Нынешняя тенденция заключается в создании многофункционального и мощного браузерного приложения, известного как одностраничное приложение (SPA), которое находится на вершине архитектуры микрослужб. Со временем фронтенд слой, часто разрабатываемый отдельной командой, разрастается и его становится все труднее поддерживать. Это то, что мы называем фронтенд-монолитом.
Идея микро-интерфейсов состоит в том, чтобы думать о веб-сайте или веб-приложении как о композиции функций, принадлежащих независимым командам. Каждая команда отвечает за отдельную область бизнеса или миссию, о которой она заботится и на которой специализируется. Команда является кросс-функциональной и развивает свои функции от начала до конца, от базы данных до пользовательского интерфейса.
Однако эта идея не нова. Он имеет много общего с автономными системами концепция. В прошлом подобные подходы назывались Frontend Integration for Verticalized Systems, но микро-фронтенды - это явно более дружелюбный и менее громоздкий термин.
Monolithic Frontends
Organisation in Verticals
Что такое современное веб-приложение?
Во введении я использовал аббревиатуру SPA. Давайте определимся с предположениями, связанными с этим термином.
Чтобы представить это в более широкой перспективе, Aral Balkan написал сообщение в блоге о том, что он называет континуумом Documents‐to‐Applications Continuum он придумывает концепцию скользящей шкалы, где сайт, построенный из статических документов, соединенных ссылками, находится на левом конце шкалы, а чистое поведение управляемое, бесконтентное приложение, подобное онлайн-редактору фотографий, находится на правом краю шкалы.
Если вы хотите разместить свой проект на левой стороне этого спектра, то интеграция на уровне веб-сервера хорошый выбор. С помощью этой модели сервер собирает и объединяет HTML-строки из всех компонентов, составляющих запрашиваемую пользователем страницу. Обновления производятся путем перезагрузки страницы с сервера или замены ее частей с помощью ajax. Gustaf Nilsson Kotte написал всеобъемлющую статью на эту тему.
Когда ваш пользовательский интерфейс должен обеспечивать мгновенную обратную связь, даже при ненадежных соединениях, чистый серверный сайт больше не является достаточным. Для реализации таких методов, как Optimistic UI или Skeleton Screens вы также должны иметь возможность обновить свой пользовательский интерфейс на самом устройстве. Термин Google Progressive Web Apps метко описывает балансирующий акт того, как быть хорошим представителем интернета(progressive enhancement) и одновременно с этим обеспечивать производительность на уровне нативного приложения. Этот вид приложения расположен где-то примерно в середине сайта-приложения на нашей шкале. Здесь уже недостаточно исключительно серверного решения. Мы должны переместить интеграцию в браузер, и именно этому посвящена эта статья.
Основные идеи, лежащие в основе Micro Frontends
- Будьте технологически независимы
каждая команда должна иметь возможность выбирать и обновлять свой стек без необходимости координировать свои действия с другими командами. [Custom Elements] (#the-dom-is-the-api) - отличный способ скрыть детали реализации, предоставляя нейтральный интерфейс другим пользователям. - Изолируйте командный код
не используйте общую среду выполнения, даже если все команды используют один и тот же фреймворк. Создавайте независимые приложения, которые являются автономными. Не полагайтесь на общие состояния или глобальные переменные. - Установите командные префиксы
создайте соглашения об именовании там, где изоляция еще невозможна. Пространство имен CSS, события, Local Storage и cookie позволяют избежать коллизий и уточнить права собственности. - Отдавайте предпочтение собственным функциям браузера по сравнению с пользовательскими API
используйте [события браузера для связи] (#parent-child-communication–DOM-modification) вместо создания глобальной системы PubSub. Если вам действительно нужно создать кросс-командный API, постарайтесь сделать его как можно более простым. - Создайте отказоустойчивый сайт
ваша фича должна быть полезна, даже если возникла ошибка JavaScript или библиотека еще не была загружена. Используйте [Universal Rendering] (#serverside-rendering–universal-rendering) и прогрессивное улучшение для улучшения воспринимаемой производительности.
DOM - это API
Custom Elements, аспект интероперабельности из спецификации веб-компонентов, являются хорошим примитивом для интеграции в браузер. Каждая команда создает свой компонент с использованием своей веб-технологии выбора и оборачивает его внутри пользовательского элемента (например, <order-minicart></order-minicart>
). Спецификация DOM этого конкретного элемента (tag-name, attributes & events) действует как контракт или публичный API для других команд. Преимущество заключается в том, что они могут использовать компонент и его функциональность без необходимости знать реализацию. Они просто должны уметь взаимодействовать с домом.
Но пользовательские элементы сами по себе не являются решением всех наших потребностей. Для решения проблемы прогрессивного улучшения, универсального рендеринга или маршрутизации нам нужны дополнительные части программного обеспечения.
Эта страница разделена на две основные области. Сначала мы обсудим [Композицию-Страницы] (#page-composition) - как собрать страницу из компонентов, принадлежащих разным командам. После этого мы покажем примеры реализации Постраничного перехода на клиенте.
Композиция Страницы
Рядом с клиент и серверными интеграции кода, написанного в различных структур сама по себе, есть много побочных тем, которые должны быть рассмотрены: механизмы изолировать от JS, в CSS избежать конфликтов, загрузке ресурсов по мере необходимости общие ресурсы между командами, ручка данные выборки и думать о хорошем загрузке государств для пользователей. Мы будем углубляться в эти темы шаг за шагом.
Базовый Прототип
Страница товара этой модели тракторного магазина послужит основой для следующих примеров.
Он оснащен элементом выбора для переключения между тремя различными моделями тракторов. При изменении имиджа продукта обновляются название, цена и рекомендации. Существует также кнопка buy, которая добавляет выбранный вариант в корзину, и мини-корзина вверху, которая соответственно обновляется.
попробуйте в браузере & посмотреть код
Весь HTML генерируется на стороне клиента с использованием чистого JavaScript и строк шаблона ES6 без дополнительных зависимостей. Код использует простое разделение состояния / разметки и повторно визуализирует всю клиентскую часть HTML при каждом изменении - никакого причудливого DOM диффинга и никакого универсального рендеринга на данный момент. Также нет разделения команд - код записывается в один файл js / css.
Интеграция На Стороне Клиента
В этом примере страница разделена на отдельные компоненты/фрагменты, принадлежащие трем командам. Команда Checkout (blue) теперь отвечает за все, что касается процесса покупки, а именно за кнопку buy и мини - корзину__. Команда Inspire (green) управляет рекомендациями продукта на этой странице. Сама страница является собственностью команды (red).
попробуйте в браузере & посмотреть код
Команда Product решает, какая функциональность включена и где она расположена в макете. Страница содержит информацию, которая может быть предоставлена самим продуктом Team, например название продукта, изображение и доступные варианты. Но он также включает в себя фрагменты (пользовательские элементы) из других команд.
Как создать пользовательский элемент?
Возьмем в качестве примера кнопку buy. Группа продукции включает в себя кнопку, просто добавив <blue-buy sku="t_porsche"></blue-buy>
в нужное место в разметке. Для работы этой команды кассу должен зарегистрировать элемент blue-buy
на странице.
class BlueBuy extends HTMLElement {
connectedCallback() {
this.innerHTML = `<button type="button">buy for 66,00 €</button>`;
}
disconnectedCallback() { ... }
}
window.customElements.define('blue-buy', BlueBuy);
Теперь каждое время когда браузер находит тег blue-buy
, вызывается connectedCallback
. this
является ссылкой на корневой DOM элемент этого пользовательского элемента. Для работы с этими элементами могут быть использованы все свойства и методы как для работы со стандартным DOM элементом, например innerHTML
или getAttribute()
.
Есть единственное требование присвоении имени вашему элементу, которое определяет спецификация, заключается в том, что имя должно включать тире (-) для поддержания совместимости с предстоящими новыми HTML-тегами. В следующих примерах используется соглашение об именовании [team_color]-[feature]
. Пространство имен team
защищает от коллизий, и таким образом право собственности на элемиент становится очевидным, просто взглянув на DOM.
Связь Родитель-Наследник (Parent-Child) / Модификация DOM
Когда пользователь выбирает другой трактор в селекторе вариантов, кнопка buy должна быть обновлена соответствующим образом. Для достижения этой цели командный продукт может просто удалить существующий элемент из DOM и вставить новый.
container.innerHTML;
// => <blue-buy sku="t_porsche">...</blue-buy>
container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';
Метод disconnectedCallback
старого элемента будет вызван синхронно, чтобы дать элементу шанс сделать все необходимые операции, например, удалить обработчики событий. После этого будет вызван connectedCallback
только что созданного элемента t_fendt
.
Другой более быстродейственный вариант - обновить аттрибут sku
на уже существующем элементе.
document.querySelector('blue-buy').setAttribute('sku', 't_fendt');
Если команда Product использует шаблонизатор, который сравниваете дом, как React, это будет сделано автоматически алгоритмом.
Чтобы поддержать это, вы можете реализовать attributeChangedCallback
и указать список observedAttributes
, для которых этот метод должен быть вызван.
const prices = {
t_porsche: '66,00 €',
t_fendt: '54,00 €',
t_eicher: '58,00 €',
};
class BlueBuy extends HTMLElement {
static get observedAttributes() {
return ['sku'];
}
connectedCallback() {
this.render();
}
render() {
const sku = this.getAttribute('sku');
const price = prices[sku];
this.innerHTML = `<button type="button">buy for ${price}</button>`;
}
attributeChangedCallback(attr, oldValue, newValue) {
this.render();
}
disconnectedCallback() {...}
}
window.customElements.define('blue-buy', BlueBuy);
Чтобы избежать повторения кода, используется метод render()
, который вызывается из connectedCallback
и attributeChangedCallback
. Этот метод объединяет необходимые данные в новую разметку innerHTML. Если вы решите использовать более сложный механизм шаблонов или фреймворк внутри пользовательского элемента, то именно здесь необходимо сделать его инициализацию.
Поддержка браузерами
The above example uses the Custom Element V1 Spec which is currently supported in Chrome, Safari and Opera. But with document-register-element a lightweight and battle-tested polyfill is available to make this work in all browsers. Under the hood, it uses the widely supported Mutation Observer API, so there is no hacky DOM tree watching going on in the background.
Совместимость с фреймворками
Поскольку пользовательские элементы являются веб-стандартом, все основные JavaScript-фреймворки, такие как Angular, React, Preact, Vue или Hyperapp, поддерживают их. Но когда вы вдаетесь в детали, в некоторых фреймворках все еще есть несколько проблем с реализацией. На сайте Custom Elements Everywhere Rob Dodson собрал набор тестов совместимости, которые показывают все еще нерешенные проблемы совместимости.
Наследник-Родитель (Child-Parent) и коммуникация менду компонентами 1го уровня / DOM события
Передача атрибутов не является достаточной для всех типов взаимодействий. В нашем примере мини-корзина должна обновляться, когда пользователь выполняет клик на кнопке Купить.
Оба фрагмента принадлежат Team Checkout (blue), поэтому они могли бы создать какой-то внутренний JavaScript API, который позволяет мини-корзине знать, когда была нажата кнопка. Но это потребовало бы, чтобы экземпляры компонентов знали друг о друге, что также было бы нарушением изоляции.
Более чистый способ-использовать механизм PubSub, где компонент может публиковать сообщение, а другие компоненты могут подписываться на определенные темы. К счастью, браузеры имеют эту встроенную функцию. Именно так работают события браузера, такие как click
, select
или mouseover
. В дополнение к собственным событиям существует также возможность создавать события более высокого уровня с помощью new CustomEvent(...)
. События всегда привязаны к узлу DOM, на котором они были созданы/отправлены. Большинство местных событий также используют всплытие событий (bubbling). Это позволяет прослушивать все события в определенном поддереве DOM. Если вы хотите прослушать все события на странице, прикрепите прослушиватель событий к элементу window. Вот как выглядит создание события blue:basket:changed
на примере:
class BlueBuy extends HTMLElement {
[...]
connectedCallback() {
[...]
this.render();
this.firstChild.addEventListener('click', this.addToCart);
}
addToCart() {
// maybe talk to an api
this.dispatchEvent(new CustomEvent('blue:basket:changed', {
bubbles: true,
}));
}
render() {
this.innerHTML = `<button type="button">buy</button>`;
}
disconnectedCallback() {
this.firstChild.removeEventListener('click', this.addToCart);
}
}
Теперь мини-корзина может подписаться на это событие в window
и получать уведомления, когда она должна обновить свои данные.
class BlueBasket extends HTMLElement {
connectedCallback() {
[...]
window.addEventListener('blue:basket:changed', this.refresh);
}
refresh() {
// fetch new data and render it
}
disconnectedCallback() {
window.removeEventListener('blue:basket:changed', this.refresh);
}
}
При таком подходе фрагмент мини-корзины добавляет прослушиватель к элементу DOM, который находится вне его области видимости (window
). Это должно быть нормально для многих приложений, но если вам это не нравится, вы также можете реализовать подход, при котором сама страница (команда Product) прослушивает событие и уведомляет мини-корзину, вызывая refresh()
на элементе DOM.
// page.js
const $ = document.getElementsByTagName;
$('blue-buy')[0].addEventListener('blue:basket:changed', function() {
$('blue-basket')[0].refresh();
});
Явный вызыв метода DOM довольно редко используется, но их можно найти, например, в API Video элемента. По возможности следует предпочесть использование декларативного подхода (изменение атрибутов).
Отрисовка на сервере (SSR) / Universal Rendering
Пользовательские элементы отлично подходят для интеграции компонентов внутри браузера. Но при создании сайта, доступного в интернете, велика вероятность того, что первоначальная производительность будет иметь сущуственное значение, и пользователи будут видеть белый экран до тех пор, пока все фреймворки js не будут загружены и выполнены. Кроме того, полезно подумать о том, что произойдет с сайтом, если JavaScript выйдет из строя или будет заблокирован. Jeremy Keith объясняет важность этого в своей электронной книге / подкасте Resilient Web Design. Поэтому возможность рендеринга основного контента на сервере является ключевой. К сожалению, спецификация веб-компонента вообще не говорит о рендеринге сервера. Нет JavaScript, нет пользовательских элементов :(
Пользовательские элементы + Server Side Includes (SSI) = ❤️
Чтобы сделать серверную отрисовку рабочей, давайте отрефакторим предыдущий пример. Каждая команда имеет свой собственный express
сервер, и метод render()
пользовательского элемента также доступен по url-адресу.
$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche
<button type="button">buy for 66,00 €</button>
Имя тега пользовательского элемента используется в качестве URL, а атрибуты становятся параметрами запроса. Теперь есть способ серверного рендеринга содержимого каждого компонента. В сочетании с пользовательским элементом <blue-buy>
, достигается что-то совсем близкое к универсальному веб-компоненту:
<blue-buy sku="t_porsche">
<!--#include virtual="/blue-buy?sku=t_porsche" -->
</blue-buy>
Комментарий #include
является частью SSI, которая является функцией, доступной на большинстве веб-серверов. Да, это тот же самый метод, который использовался в те далекие дни, чтобы встроить текущую дату на наши статичные веб-сайты. Есть также несколько альтернативных методов, таких как ESI, nodesi, compoxure и tailor, но для наших проектов SSI зарекомендовала себя как простое и невероятно стабильное решение.
Комментарий #include
заменяется ответом от /blue-buy?sku=t_porsche
перед тем, как веб-сервер отправит полную страницу в браузер. Конфигурация в nginx выглядит следующим образом:
upstream team_blue {
server team_blue:3001;
}
upstream team_green {
server team_green:3002;
}
upstream team_red {
server team_red:3003;
}
server {
listen 3000;
ssi on;
location /blue {
proxy_pass http://team_blue;
}
location /green {
proxy_pass http://team_green;
}
location /red {
proxy_pass http://team_red;
}
location / {
proxy_pass http://team_red;
}
}
The directive ssi: on;
enables the SSI feature and an upstream
and location
block is added for every team to ensure that all urls which start with /blue
will be routed to the correct application (team_blue:3001
). In addition the /
route is mapped to team red, which is controlling the homepage / product page.
Директива ssi: on;
включает функцию SSI, и для каждой команды добавляется блок upstream
и location
, чтобы гарантировать, что все URL-адреса, начинающиеся с /blue
, будут перенаправлены в правильное приложение (team_blue:3001
). Помимо этого корневой адрес /
принадлежит команде red
, которая контролирует главную страницу / страницу товара.
Эта анимация показывает магазин тракторов в браузере, с отключенным JavaScript.
Кнопки выбора варианта теперь являются реальными ссылками, и каждый щелчок приводит к перезагрузке страницы. Терминал справа иллюстрирует процесс направления запроса на страницу в red
команде, которая управляет страницей продукта, а затем разметка дополняется фрагментами из команд blue
и green
.
При повторном включении JavaScript будут видны только сообщения журнала сервера для первого запроса. Все последующие изменения трактора обрабатываются на стороне клиента, как и в первом примере. В более позднем примере данные продукта будут извлечены из JavaScript и загружены через REST api по мере необходимости.
Вы можете обкатать этот код в вашем локальном окружении. Необходимо только установить Docker Compose.
git clone https://github.com/serzn1/micro-frontends.git
cd micro-frontends/2-composition-universal
docker-compose up --build
Затем Docker запескает nginx на 3000 порту и собирает ораз nodejs приложениядля каждой команды. Когда вы открываете http://127.0.0.1:3000/ в браузере, вы должны увидеть красный трактор. Комбинированные логи docker-compose
позволяет легко видеть, что происходит в сети. К сожалению, нет никакого способа контролировать цвета в логах, поэтому вам придется смириться с тем, что команда blue
может быть выделена зеленым цветом :)
Файлы src
сопоставляются в отдельные контейнеры, и nodejs приложение перезапустится, когда вы внесете изменение кода. Изменение nginx.conf
требует перезапуска docker-compose
, чтобы применить изменения. Так что не стесняйтесь поиграться с этим и дать обратную связь.
Закрузка данных & Состояния загрузки
Недостатком подхода SSI/ESI является то, что самый медленный фрагмент определяет время отклика всей страницы.
Поэтому хорошо, когда ответ фрагмента может быть кэширован.
Для фрагментов, которые дорого производить и трудно кэшировать, часто рекомендуется исключить их из первоначального рендеринга.
Они могут быть загружены асинхронно в браузере.
В нашем примере фрагмент green-recos
, который показывает персонализированные рекомендации, является отличным примером для этого.
Одним из возможных решений было бы то, что команда red
просто пропускает SSI Include инструкцию.
До
<green-recos sku="t_porsche">
<!--#include virtual="/green-recos?sku=t_porsche" -->
</green-recos>
После
<green-recos sku="t_porsche"></green-recos>
Важно знать: Пользовательские элементы не могут быть самозакрывающимися тегами, поэтому версия <green-recos sku="t_porsche" />
не будет работать корректно.
Отрисовка происходит только в браузере.
Но, как видно из анимации, это изменение теперь привело к существенному reflow страницы.
Область рекомендаций изначально пуста.
Код команды green
загружается и выполняется.
Делается вызов API для получения персонализированной рекомендации.
Выводится разметка рекомендаций и запрашиваются соответствующие изображения.
Фрагмент теперь нуждается в большем пространстве и меняет высоту страницы.
Существуют различные варианты, чтобы избежать раздражающего скачка страницы, подобного этому.
Команда red
, которая управляет страницей, может зафиксировать высоту рекомендаций.
На адаптивном сайте часто сложно определить высоту, потому что она может отличаться для разных размеров экрана.
Но более важный вопрос заключается в том, что такого рода межкомандное соглашение создает тесную связь между командами red
и green
.
Если команда green
хочет ввести дополнительный подзаголовок в элементе reco
, она должна будет согласовать новую высоту блока с командой red
.
Обе команды должны были бы развернуть свои изменения одновременно, чтобы избежать сломанной компоновки.
Лучший способ - использовать технику, называемую Sceleton Screens/Экран плейсхолдер.
Команда red
оставляет SSI green-recos
включенным в разметку.
Кроме того, команда green
изменяет метод рендеринга на стороне сервера своего фрагмента таким образом, чтобы он создавал схематическую версию содержимого.
Разметка плейсхолдера может повторно использовать части стилей макета реального контента.
Таким образом, плейсхолдер резервирует необходимое пространство на сайте и заполнение фактического содержимого не приводит к скачку.
Техника экрана-плейсхолдера (Skeleton Screen) также очень полезна на клиенте. В момент появления пользовательских элементов в DOMе они могут быть показаны мгновенно, задолго до момента загрузки реальных данных с сервера.
Даже при изменении атрибута, например для элемента выбора, вы можете переключиться на экран плейсхолдер до тех пор, пока не поступят новые данные. Таким образом, пользователь осведомлен о том, что что-то происходит во фрагменте страницы. Но когда ваш API реагирует достаточно быстро, короткое мерцание плейсхолдера между старым и новым состоянием может раздражать пользователей. Кеширование старых данных или использование тайм-аутов может помочь избавить пользователей от раздражения. Поэтому используйте эту технику с умом и постарайтесь получить обратную связь от пользователей.
Навигация между страницами
скоро…
Подрисывайтесь на Github Repo и будьте в курсе событий
Дополнительные ресурсы
- Книга: Micro Frontends in Action Написано мной. В настоящий момент в статусе Mannings Early Access Programm (MEAP)
- Доклад: Micro Frontends - MicroCPH, Copenhagen 2019 (Слайды) The Nitty Gritty Details or Frontend, Backend, 🌈 Happyend
- Доклад: Micro Frontends - Web Rebels, Oslo 2018 (Слайды) Think Smaller, Избегайте монолита, ❤️the Backend
- Слайды: Micro Frontends - JSUnconf.eu 2017
- Доклад: Break Up With Your Frontend Monolith - JS Kongress 2017 Elisabeth Engel рассказывает о реализации Micro Frontends в gutefrage.net
- Статья: Micro Frontends Article by Cam Jackson on Martin Fowlers Blog
- Пост: Micro frontends - a microservice approach to front-end web development Tom Söderlund объясняет ключевые понятия и делится ссылками на тему
- Пост: Microservices to Micro-Frontends Sandeep Jain подитожит ключевые принципы лежащие в основе микросервисов и микро фронтендов
- Коллекция ссылок: Micro Frontends by Elisabeth Engel обширный список постов, докладов, инструментов и других ресурсов по теме
- Awesome Micro Frontends список актуальных ссылок от Christian Ulbrich 🕶
- Custom Elements Everywhere Making sure frameworks and custom elements can be BFFs
- Заказать трактор вы можете на сайте manufactum.com :)
Этот магазин разраьботан двумя командами с использованием изложенной выше техники микрифронтендов.
Сопутствующие техники
- Посты: Cookie Cutter Scaling David Hammet написал серию статей по теме.
- Вики: Java Portlet Specification Спецификация, в которой рассматриваются аналогичные темы для построения корпоративных порталов.
Скоро…
- Примеры использования
- Навигация между страницами
- soft vs. hard навигация
- universal router
- …
- Навигация между страницами
- Интересные темы
- Изолирование CSS / Понятный UI / Стилизация & Библиотеки шаблонов
- Производительность при первой загрузке
- Производительность во время работы
- Подгрузка CSS
- Подгрузка JS
- Интеграционное тестирование
- …
Крнтрибуторы
- Koike Takayuki перевел сайт на Японский.
- Jorge Beltrán перевел сайт на Испанский.
- Sergei Babin перевел сайт на Русский.
- Bruno Carneiro перевел сайт на Португальский.
- Soobin Bak перевел сайт на Корейский.
Этот сайт сгенерирован при помощи Github Pages. Вы можете найти исходники на serzn1/micro-frontends.