Всем привет. Меня зовут Алексей. Сейчас я работаю 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) 

exposes: {
   './MODULE_NAME': 'path/to/module'
  },

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

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 или предметно-ориентированной архитектуры.