В первой части статьи я ответил на два основных вопроса, связанных с архитектурой микрофронтенда - что это такое и для чего. С этого момента эта статья посвящена тому, как это делается. В этой части, в частности, дается общий обзор технических решений, поддерживающих микрофронты, и объясняется, как это работает с точки зрения конечного пользователя.

Как все это работает?

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

Интеграция во время сборки

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

Чтобы это произошло на первых этапах трансформации, мы выбрали самое простое решение, которое только можно себе представить: мы рассматривали наш монолит как приложение-контейнер и использовали NPM для загрузки и интеграции пакетов за нас. Мы создали модуль под названием Node Package Provider, поместили в него package.json файл и перечислили все наши микрофронтенды как зависимости.

Затем мы создали шаг в плане сборки монолита, на котором программа буквально входила в каталог Node Package Provider и запускала в нем npm install скрипт. Мы опубликовали все отдельные приложения в нашем локальном репозитории пакетов и настроили Node Package Provider, чтобы он мог получить к ним доступ. Все пакеты были установлены в этом каталоге и позже были внедрены в определенные представления со ссылкой на него.

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

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

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

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

Интеграция во время выполнения

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

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

<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Title</title>
  </head>
  <body>
    <h1>Header</h1>
    <!--# include file="$PAGE.html" -->
  </body>
</html>

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

server {
    listen 8080;
    server_name localhost;
root /usr/share/nginx/html;
    index index.html;
    ssi on;
rewrite ^/$ http://localhost:8080/page1 redirect;
location /page1 {
      set $PAGE 'page1';
    }
    location /page2 {
      set $PAGE 'page2';
    }
    location /page3 {
      set $PAGE 'page3'
    }
}

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

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

Для этого мы выбрали сильно модифицированную версию библиотеки с открытым исходным кодом Tailor, созданную Zalando. Он позволяет нам определять все маршруты, шаблоны и фрагменты в простых и удобных файлах конфигурации, которые мы можем разделить и поддерживать отдельно. Мы назвали его Templating Engine и установили его в качестве основной точки входа в наше приложение для частей, которые уже переведены на этот подход. Вот как это работает.

Каждый раз, когда пользователь входит на наш веб-сайт, запрос передается в Templating Engine, которая на основе URL-адреса запроса распознает, какой шаблон ожидает пользователь, загружает его, а затем заполняет его содержимым соответствующих микрофронтендов.

Здесь вы можете увидеть пример маршрута к отображению шаблона. Когда наш пользователь переходит на один из этих URL-адресов, Templating Engine загружает соответствующий шаблон.

routeMap:
  "/": "/index.html"
  "/thiscompany": "/companyHub/pages/thisCompany.html"
  "/jobs": "/companyHub/pages/jobs.html"
  "/contacts": "/companyHub/pages/contacts.html"
  "/reviews": "/companyHub/pages/reviews.html"

Вот примерный шаблон. Как видите, в большинстве случаев это просто набор фрагментов, каждый из которых представляет отдельный микрофронтенд. Здесь для каждого фрагмента может быть предоставлена ​​некоторая конфигурация, но самое главное - уникальный идентификатор.

<fragment id="ch-background-image" slot="ch-background-image"
    timeout="30000" async></fragment>
<fragment id="ch-meta-data-header" slot="ch-header"
    timeout="30000" async></fragment>
<fragment id="ch-jobs" slot="ch-block-main"
    timeout="30000"></fragment>
<fragment id="ch-assets" slot="ch-block-end"
    timeout="30000" async></fragment>

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

fragments:
  ch-assets: http://localhost:8080/assetslinks
ch-background-image: http://localhost:8080/v1/background
    /companyId/{companyId}/lang/{langName}/?{additionalParams}
ch-jobs: http://localhost:8080/v1/jobs/companyId/{companyId}
    /lang/{langName}/?{additionalParams}
ch-meta-data-header: http://localhost:8080/v1/header
    /companyId/{companyId}/lang/{langName}/?{additionalParams}

Мы создали этот инструмент еще и потому, что на самом деле существуют другие общие задачи, о которых можно было бы позаботиться на этом уровне, вместо того, чтобы делегировать их каждому отдельному микроприложению. Список таких задач включает, помимо прочего, присоединение общих общих библиотек к каждой странице, сбор и отправку базовых данных отслеживания, связанных с конкретным маршрутом, выбранным пользователем, или передачу информации об A / B-тестах, в которых находится пользователь.

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

Связь микрофронтенда с издателем и подписчиком

Например, в настоящее время мы используем простую библиотеку JavaScript с именем PubSubJS, которая реализует хорошо известный шаблон «издатель и подписчик». В этом примере реализации вы можете видеть, что мы импортируем эту библиотеку вместе со списком сообщений из нашего репозитория пакетов. Затем есть компонент React, который представляет собой модальный вход для наших пользователей. После того, как он установлен на странице, он подписывается на конкретное сообщение и будет вести себя определенным образом при публикации такого сообщения.

import {
  PubSub,
  CANDIDATE_PROFILE_EVENTS,
} from '@stepstone/pub-sub';
class LoginModal extends React.Component {
  public componentDidMount() {
    PubSub.subscribe(
      CANDIDATE_PROFILE_EVENTS.SHOW_LOGIN_MODAL,
      (msg, data) => { /* ... */ }
    );
  }
}

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

PubSub.publish('CANDIDATE_PROFILE_SHOW_LOGIN_MODAL');

Фактически, вы можете ввести именно эту строку кода в консоли JavaScript, находясь на одной из наших домашних страниц, например stepstone.de. Поскольку этот API доступен глобально в объекте окна, вы увидите это поведение в действии. Это, в свою очередь, может пригодиться, например, в целях тестирования.

Правда в том, что отдельная библиотека для «издателя и подписчика» больше не нужна, учитывая текущее состояние общедоступных API в наших браузерах. Этот точный шаблон используется в JavaScript в течение многих лет - обычные события, такие как щелчок или наведение, работают точно так же, просто они называются по-другому: есть элементы, которые прослушивают событие, а не подписываются на сообщения, и есть элементы, которые отправляют события вместо публикации сообщений.

Имея доступ к мощному API под названием CustomEvent, который теперь поддерживается во всех основных браузерах, можно воссоздать тот же метод связи без необходимости использования PubSubJS или любой другой библиотеки, как в этом примере.

element.addEventListener(
  'myEvent',
  (msg, data) => { /* ... */ }
);
element.dispatchEvent(
  new CustomEvent('myEvent')
);

Связь между микрофронтами с общим состоянием

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

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

Для нас это звучит потрясающе, но это все еще чисто теоретический аргумент - мы еще никак не тестировали этот подход. Мы обдумываем эту идею, потому что добавленная стоимость может быть огромной, но это всегда компромисс. Простая реализация, такая как PubSubJS, которую мы используем, предполагает минимальный объем кода, совместно используемый командами, и очень небольшую потребность в обслуживании, в то время как глобальное общее состояние, основанное на какой-то конкретной технологии, будет иметь гораздо более обширные последствия для настоящего и будущего развития.

Но почему именно Redux? В конце концов, они говорят, что вы можете полностью заменить его на React Hooks и Context API. Что ж, это может быть правдой в пределах одного приложения React, но, учитывая тот факт, что наши представления состоят из нескольких отдельных приложений такого типа, мы не нашли способ заставить их использовать одно состояние с учетом этих инструментов (хотя это кажется возможным выполнить с добавлением библиотеки ReactN). Redux, с другой стороны, не связан с React по определению, может быть создан отдельно и легко предоставлен всем приложениям React на одной странице.

Эта статья публикуется частями, и на этом вторая из них заканчивается. В следующей части вы узнаете о том, как мы создаем микрофронтенды, инструменты и процессы, которые помогают нам контролировать такую ​​распределенную систему.

продолжить чтение

использованная литература