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

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

Современные микрофронты являются логичным продолжением технологии iframe.

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

Но сначала давайте разберемся, что такое микрофронты.

Немного о микрофронтах

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

Есть HOST-приложение, которое доступно пользователю, и  есть REMOTE-приложение, которое встраивается в HOST. В этой ситуации можно даже не использовать микрофронты. Ситуация меняется, когда появляется много различных remote-приложений, которые нам надо вставить в host.

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

Можно выделить второй тип приложений:

На схеме представлена упрощенная версия интернет-магазина, разделённая на микрофронты. Есть основная часть HOST, которая включает в себя модуль каталога (без него интернет-магазин невозможен). Модули корзины и оплаты предоставляют отдельные компоненты и функции, которые не могут использоваться независимо от основного приложения, но можно их спроектировать таким образом, что они будут предоставляться и другим приложениям. Разные интернет-магазины могут брать, допустим, модуль оплаты и использовать у себя. Данные для этих функций и компонентов можно предоставлять в виде props или аргументов, что делает их переиспользуемыми. 

Зачем?

На мой взгляд, применение микрофронтов позволит:

  1. увеличить эффективность разработки: так как проект разделён на небольшие части, это позволит сконцентрироваться на конкретной задаче и выполнить её быстрее;

  2. более эффективно планировать время разработки;

  3. обрести большую гибкость: проще изменять ui и логику небольших частей проекта;

  4. улучшить качество проекта: разделение на небольшие части помогает легче концентрироваться на них и более качественно прорабатывать.

Пример реализации микрофронтов

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

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

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

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

Когда пользователь зайдёт в наше приложение APP, он не увидит других наших приложений. Они будут загружаться по мере необходимости, например, при переходе по определенному роуту. Если пользователю не нужна запись к врачу, он не получит код, который отвечает за этот функционал. Можно предположить, что эту проблему решает и lazy loading. Отчасти это правда, но lazy loading не позволит нам выделить каждый модуль в отдельное приложение и разместить его по своему url-адресу.  

Преимущества

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

Несомненным преимуществом является то, что каждый модуль может разрабатываться отдельной командой с использованием своего технического стека. Таким образом, если для одного модуля подходит react, а для другого svelte, то реализация труда не составит. 

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

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

Недостатки

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

Может показаться, что можно версионировать каждый модуль. Это возможно, но нежелательно. Допустим, выкатывается новая версия модуля. Разработчикам HOST-приложения необходимо подтянуть новую версию и сделать релиз, что требует дополнительного времени разработки. В таком случае — зачем нам использовать микрофронты, если можно оформить модуль в npm-пакет?

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

ModuleFederationPlugin

В основном для frontend-приложений используются разные сборщики, и у большинства есть свои плагины. Например, для Vite можно использовать vite-plugin-federation для реализации микрофронтов. Хоть Vite и набирает популярность, но первенство за собой сохраняет Webpack, поэтому разберем процесс настройки с его использованием.

Webpack с 5-ой версии в своем пакете имеет модуль moduleFederationPlugin, который и отвечает за настройку микрофронтов.

const {ModuleFederationPlugin} = webpack.container;

В общем случае настройка плагина выглядит так:

plugins: [
 new ModuleFederationPlugin({
  name: 'MAIN',
  filename: 'remote.js',
  remotes: {
   'MODULE_NAME': 'REMOTE_MODULE_NAME@remote_url/remote.js',
  },
  exposes: {
   './MODULE_NAME': 'path/to/module'
  },
  shared: {
   react: {
    singleton: true,
    requiredVersion: dependencies['react']
   },
   'react-dom': {
    singleton: true,
    requiredVersion: dependencies['react-dom']
   }
  }
 })
 ],

Разберем поля:

1)  name: 'MAIN'

Название нашего приложения-микрофронта.

2) filename: 'remote.js'

Название файла, в который сборщик запишет информацию о нашем приложении и как достать те модули, которые мы отдаём (у каждого микрофронта может быть несколько модулей).

3) 

remotes: {
 'MODULE_NAME': 'REMOTE_MODULE_NAME@remote_url/remote.js',
}

Объект, ключами которого являются названия удалённых модулей (называем как хотим, для внутреннего использования), а значениями — строки, в которых указывается название удаленного модуля из поля name удаленного микрофронта и url-адрес, по которому можно найти файл с информацией.

4) 

remotes: {
 'MODULE_NAME': 'REMOTE_MODULE_NAME@remote_url/remote.js',
}

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

5) 

shared: {
 react: {
  singleton: true,
  requiredVersion: dependencies['react']
 },
 'react-dom': {
  singleton: true,
  requiredVersion: dependencies['react-dom']
 }
}

Указываются зависимости нашего проекта, которые необходимы для работы нашего микрофронта. В данном случае указываем, что react и react-dom нам нужны в единственном экземпляре и той версии, которая указана в package.json. Если приложение, которое использует наш микрофронт уже имеет такие же зависимости, то наш микрофронт их не отдаст.

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

Условимся, что у нас есть main-приложение. Мы в него хотим интегрировать приложение remote.

Настройки для remote выглядят так:

new ModuleFederationPlugin({
   name: 'REMOTE',
   filename: 'remote.js',
   exposes: {
       './module1': './src/...'
   },
   shared: {
       react: {
           singleton: true,
           requiredVersion: dependencies['react']
       },
       'react-dom': {
           singleton: true,
           requiredVersion: dependencies['react-dom']
       }
}
})

Даём приложению название REMOTE, а модулю внутри — название module1 и указываем путь до него. В свою очередь, настройка нашего main имеет вид:

new ModuleFederationPlugin({
 name: 'MAIN',
 filename: 'remote.js',
 remotes: {
  'REMOTE_LOCAL_NAME': 'REMOTE@http://localhost:9001/remote.js',
 },
 shared: {
  react: {
   singleton: true,
   requiredVersion: dependencies['react']
  },
  'react-dom': {
   singleton: true,
   requiredVersion: dependencies['react-dom']
  }
 }
})
],

Удалённому приложению для внутреннего использования дали имя REMOTE_LOCAL_NAME. Допустим, удаленное приложение задеплоено по адресу http://localhost:9001. Мы обращаемся к приложению REMOTE по этому адресу и забираем файл remote.js.

Теперь внутри нашего main мы можем использовать удаленный модуль:

1) если это react-компонент —

const RemoteComponent = React.lazy(() => import('REMOTE_LOCAL_NAME/module1'));

2) если это функция или объект с функциями —

import remoteFn from  = 'REMOTE_LOCAL_NAME/module1'

Сложности возникают, когда нам необходимо в react-приложение вставить vue-компонент, и наоборот. Для этого нужно удалённому фронту отдать код, который инициализирует react-/vue-приложение, или отдать приложение целиком.

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

Хранилище данных

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

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

Предлагаю передавать такие данные в удалённые компоненты в виде props, а в функции — в параметрах. В итоге мы получим для удалённых модулей независимость от глобального store. Ну а внутри можно использовать для реализации своего хранилища библиотеку, которая лучше всего подходит. Другими словами, разделяем глобальное хранилище и локальное.

Typescript

Сейчас почти в каждом проекте используется typescript. Это очень полезный инструмент, и отказываться от него ради микрофронтов не хочется. К счастью, есть плагин, который поможет нам поддержать типизацию. В версии 2 плагин использовал внутри себя ModuleFederationPlugin, но от этого отказались, и при использовании последней версии (на данный момент 3.0.1) необходимо использовать оба плагина.
В базовом случае его настройка ничем не отличается от ModuleFederationPlugin —

import {FederatedTypesPlugin} from '@module-federation/typescript';


plugins: [
new ModuleFederationPlugin( config ),

new FederatedTypesPlugin({
   federationConfig: config
}),
...
]

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

При сборке проекта в папке build появляется файл __types_index.json (если вы не указали другое название в конфиге) и папка @mf-types.

Файл __types_index.json описывает, какие типы есть и откуда их забрать, а в папке хранятся сами файлы декларации типов. Если вы запускаете devServer, обязательно укажите поле static, тогда плагин корректно сумеет положить типы в нужное место.

Ok. Микрофронт теперь умеет отдавать типы. Как же mai- приложение узнает про эти типы и получит их? Всё просто. Настройка плагинов точно такая же, как описано выше. При сборке проекта, плагин обращается к микрофронту, смотрит файл __types_index.json, забирает все типы, какие есть и кладет их в папку @mf-types рядом с конфигом webpack. Да, немного неудобно, что надо собрать проект, чтобы получить типы, но с другой стороны, мы не теряем мощный инструмент типизации. Все названия папок и файлов можно изменить в конфиге.

Скорее всего, потребуется небольшая правка в tsconfig, чтобы typescript увидел новые типы —

"paths": {
 "*": ["*", "./webpack/@mf-types/*"]
}

Вывод

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

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

Использование оправдано в случаях, если у нас:

  1. большой монорепозиторий;

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

  3. слишком долгий процесс релиза (постоянные очереди на релиз);

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

  5. сложно масштабировать проект.

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

Комментарии (15)


  1. LAutour
    22.12.2023 10:46

    удалил (не туда написал)


  1. gmtd
    22.12.2023 10:46

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

    Предлагаю передавать такие данные в удалённые компоненты в виде props, а в функции — в параметрах. В итоге мы получим для удалённых модулей независимость от глобального store. 

    На фронте все хранилища - реактивные. С пропсами вся реактивность потеряется.


    1. starosta2012 Автор
      22.12.2023 10:46

      Добрый день. Спасибо за комментарий.

      Возможно плохо проработал этот момент. Я с Вами полностью согласен. Имелось ввиду, что стор в host приложении хранит данные, которые нужны многим remote компонентам. И эти данные прокидывать в компоненты в виде пропсов.

      В этом случае мы можем потерять немного в оптимизации, но минимизируем сложность поддержки хранилища в виде отдельного remote приложения.

      Возможно не так понял комментарий


      1. gmtd
        22.12.2023 10:46

        Допустим вы держите в host параметры isAuthenticated и user
        Вы не можете их прокинуть как пропсы, которые просто передадутся по значению, потому что микрофронтенды должны сразу знать, когда их состояние изменится. Поэтому нужно что-то типа pub-sub подписки на события и возможность самим обновлять общие данные


        1. starosta2012 Автор
          22.12.2023 10:46

          Согласен с Вами. Если возьмем к примеру React, то насколько я знаю, компонент перерендерится, если поменяются пропсы. А если мы их забираем из store, то и компонент внутри которого расположен remote компонент тоже перерендерится.

          Что касается чистого js, html, то да. Нужен механизм подписки. Цель этой статьи - показать что такое микрофронты, с чего начать и как настроить самые базовые вещи.
          Организация store тянет на отдельную статью с обзором всех возможных вариантов.


  1. quikki
    22.12.2023 10:46

    Спасибо за контент, просто понятно, для чайников ыы :3


    1. starosta2012 Автор
      22.12.2023 10:46

      Спасибо за комментарий)


  1. tapthoppe
    22.12.2023 10:46

    Спасибо за туториал!

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

    Если вы отдаёте хосту код, который инициализирует remote react-/vue-приложение, то в параметры ф-ции (условно bootstrapApplication) можно прокинуть экземпляр стора. В том же vue есть замечательный DI.

    Само хранилище может быть спроектировано с использованием паттерна CQRS.


    1. starosta2012 Автор
      22.12.2023 10:46

      Добрый день. Спасибо за комментарий.

      Возможно плохо проработал этот момент.

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

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

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


  1. ILaeeeee
    22.12.2023 10:46

    Пробежался по началу статьи и не понял сути. Ок, про фреймы понятно. Далее, непонятно.

    Есть виджеты. Есть компоненты. В чём отличие микрофронта от этого?


    1. VanquisherWinbringer
      22.12.2023 10:46

      YandexGo приложением пользовались? Там внутри одного приложения можно запустить другое как часть его - например Яндекс.Еда. Так вот это оно.


    1. starosta2012 Автор
      22.12.2023 10:46

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

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


      1. ILaeeeee
        22.12.2023 10:46

        Меня интересует терминалогия.

        Если обобщить, то мы говорим о модульность.

        Есть старые понятия.

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

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

        То что тут описали:

        YandexGo приложением пользовались? Там внутри одного приложения можно запустить другое как часть его - например Яндекс.Еда. Так вот это оно.


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

        Терминами "виджет" и "компонент" можно описать.

        Почему понадобился ещё один термин "микрофронт" и в чём его отличие от двух первых? Может я слишком долго спал и сейчас уже по другому говорят?

        В догонку, чем это от микросервисов отличается?


        1. starosta2012 Автор
          22.12.2023 10:46

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

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

          Микрофронты - уже достаточно устоявшийся термин)


        1. Sap_ru
          22.12.2023 10:46

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