Как стать автором
Обновить
0
Delivery Club Tech
Лидер рынка FoodTech в России

Module Federation: простая загрузка динамических модулей

Время на прочтение8 мин
Количество просмотров14K

Всем привет! Меня зовут Евгений, я работаю frontend-разработчиком в платформенной команде. Моя задача — помогать другим frontend-разработчикам выполнять их задачи эффективнее. Мы в Delivery Club больше года назад внедрили подход с микрофронтендами, о чём писали здесь. Вы можете найти и много других статей с описанием этого подхода.

После выхода стабильной версии Webpack 5 мы решили использовать плагин Module Federation в качестве основного способа загрузки микрофронтендов. В этой статье расскажу, с какой проблемой столкнулся при загрузке динамических модулей и как её решил. Описывать будут на примере плагина Module Federation во всех деталях. Если вы слышите про этот инструмент впервые, то советую предварительно ознакомиться.

Суть плагина Module Federation

Раньше, чтобы загрузить внутри веб-приложения какую-либо часть другого веб-приложения приходилось использовать iframe. Если нужна была двусторонняя связь, то приходилось создавать «костыли» для обмена событиями. Например, чтобы обработать точечные клики внутри или снаружи iframe.

Плагин Module Federation позволяет делать то же самое более понятно: вы можете загружать целые модули веб-приложения, которые не были там изначально, прямо в коде во время исполнения. Для этого можно использовать нативный import(), а через него загружать всё, что угодно: строку, объект, функцию или регистрировать полноценный веб-компонент.

Далее будем пользоваться терминами: 

  • Host-приложение — загружает в себя какой-либо удалённый модуль или целое веб-приложение.

  • Удалённый модуль — удалённое фронтенд-приложение, которое загружается в host-приложение.

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

Я опишу два способа загрузки удалённых модулей с помощью:

  1. статического адреса, то есть не меняющегося;

  2. динамического адреса, который меняться по каким-то условиям. Например, в Delivery Club мы используем динамические адреса для версионирования приложения по тегу сборки, и они регулярно меняются. 

Демо-проект

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

В репозитории есть две директории с примерами загрузки веб-компонента: 

  • static-url со статического адреса;

  • dynamic-url с динамического адреса.

Статические удалённые модули

Обратимся к документации Webpack по Module Federation. В случае со статическим адресом всё просто: указываем название и адрес удаленного модуля (рис. 1), а host-приложение забирает его по указанному адресу.

Рис. 1
Рис. 1

Рассмотрим пример с загрузкой веб-компонента. Приведенный код можно найти в репозитории на GitHub в директории static-url.

Host-приложение — app1

В app1 выполняем три шага:

  1. В index.ts загружаем модуль, например, через import(), и вставляем полученное содержимое в div в разметке. 

const div = document.getElementById('div');

import("app2/main")
   .then(module => {
     console.log("? ~ app1: Success import from app2! Module: ", module);
       div.innerHTML = module.default;
})

app1/src/index.ts

  1. В webpack.config.js в объекте remotes в качестве ключа указываем название удалённого модуля и значение — адрес удалённого модуля. С этого адреса мы загрузим remoteEntry.js, который является входной точкой удалённого модуля. Для простоты host-приложение и удалённый модуль будут находиться на соседних портах localhost.

module.exports = {
… 
plugins: [   
  new ModuleFederationPlugin({     
   name: "app1",     
   remotes: {       
     app2: "app2@https://localhost:4002/remoteEntry.js",     
    }   
  }),  
 … 
],
…
}

app1/webpack.config.js

  1. В index.html указываем тег веб-компонента из удалённого модуля. В нашем случае мы зарегистрировали его как app2-tag-name.

<body>
   … 
   <h1>App 1</h1>
   <div id="div"></div>
   <app2-tag-name></app2-tag-name>
   … 
</body>

app1/public/index.html

Удалённый модуль — app2

Сейчас мы подготовим удалённый модуль, который загрузим в host-приложение app1. В app2 выполняем два шага:

  1. В экспортируемом файле, например, index.ts, регистрируем веб-компонент или выводим что-нибудь в export.

class GreatComponent extends HTMLElement {
 constructor() {
     super();
     const shadowRoot = this.attachShadow({mode: 'open'});
     shadowRoot.innerHTML = `
       <strong>I am content inside the web-component from app2!</strong>`;
   }
}
window.customElements.define('app2-tag-name', GreatComponent);
export default 'I am data from app2!'

app2/src/index.ts

Здесь мы создали веб-компонент GreatComponent с некоторым контентом внутри. Веб-компонент регистрируем в customElements.
Также для примера добавим что-нибудь в export модуля, чтобы показать возможности передачи любых других данных. В нашем случае это будет строка: “I am data from app2!”.

  1. В webpack.config.js указываем название удалённого модуля и экспортируемый файл. Им может быть любой файл или какой-нибудь отдельный компонент. В нашем случае это будет файл index.ts из пункта выше.

module.exports = { 
 plugins: [   
   new ModuleFederationPlugin({     
     name: "app2",     
     filename: remoteEntry.js,     
     exposes: {       
        "./main": "./src/index.ts",     
      },   
    }), 
 … 
 ],
…

app2/webpack.config.js 

При запуске двух проектов мы увидим следующий результат (рис. 2). В теле host-приложения мы отобразили удалённое приложение буквально за пять шагов.

Рис. 2
Рис. 2

Мы разобрались, как работает загрузка со статического адреса. Перейдём к динамическому адресу.

Динамические удалённые модули

Если адрес загрузки удалённого модуля по каким-то причинам меняется, то модуль загружается иначе. Как я уже писал ранее, мы в Delivery Club используем динамические адресы, чтобы запрашивать ту версию веб-приложения, с которой пользователь взаимодействует в данный момент. Иначе, в случае «ленивой» загрузки нового чанка, пользователь может получить ошибку 404, и для корректной работы ему придётся перезагрузить страницу. Чтобы этого не произошло, к каждой версии мы прикладываем тег с версией сборки, где указан год, номер недели и номер конкретной сборки. Например, тег 2134.03 означает 2021 год, 34 неделя и третья сборка.

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

По официальной документации нужно: 

  • написать ключевое слово “promise”;

  • описать тело скрипта с объектом Promise();

  • по ключу названия удалённого модуля положить содержимое Promise в виде строки. 

На рисунке 3 показан пример из документации с динамическим адресом. В нём меняется адрес по значению, которое нужно брать из url-параметров.

Рис. 3
Рис. 3

По сути, нам приходится создавать объект Promise(), чтобы получить удалённый модуль во время исполнения программы. После этого с помощью метода resolve() возвращаем объект с интерфейсом get/init и затем добавляем скрипт в тег head HTML-страницы.

Проблемы динамических модулей

В способе из документации есть несколько проблем:

  • редактировать скрипт в виде строки неудобно, это затрудняет процесс отладки кода;

  • если модулей несколько, то для каждого модуля необходимо вставлять текст со своим скриптом;

  • невозможно прокидывать параметры для динамического адреса, кроме доступных через объект window

Что мы хотим видеть в итоге

  1. Сделать скрипт с объектом Promise() в виде обычной функции, чтобы редактировать как обычный код.

  2. Иметь возможность передавать любые параметры.

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

  4. Упростить декларирование нескольких удалённых модулей. Например, разработчику нужно указать только название тега веб-компонента и откуда его запросить.

Приведённый код можно найти в репозитории на GitHub в директории dynamic-url.

Решение

Решение первых двух пунктов будет таким:

const getPromise = () => new Promise(resolve => {
 const remoteTagName = 'CUSTOM_ELEMENT_TAG';
 const url = 'CUSTOM_ELEMENT_URL';
 …
});

const getRemoteModule = (remoteName, remoteUrl) => (
getFuncBody(getPromise)
   .replace('CUSTOM_ELEMENT_TAG', remoteName)
   .replace('CUSTOM_ELEMENT_URL', remoteUrl)
);

src/mf-remotes.js

Функция getFuncBody() возвращает строковое представление тела функции, чтобы содержимое начиналось с “new Promise…”.  Параметры внутрь Promise() можем положить с помощью метода replace() уже после преобразования функции в строку.

Теперь у нас есть просто функция с Promise(). Можем использовать любые нативные инструменты JavaScript, чтобы добавить выполнение любой асинхронной операции:

const getPromise = () => new Promise(resolve => {
 const remoteTagName = 'CUSTOM_ELEMENT_TAG';
 const url = 'CUSTOM_ELEMENT_URL';
  
fetch({version}/remoteEntry.js;
  const remote_url = url + `/${version}/remoteEntry.js`;
      
  const script = document.createElement('script');
  script.src = remote_url
  script.onload = () => {
    const proxy = {
      get: (request) => window[remoteTagName].get(request),
      init: (arg) => {
        try {
           return window[remoteTagName].init(arg);
         } catch(e) {
           console.log('remote container already initialized');
         }
      }
   }
   resolve(proxy);
  }
  document.head.appendChild(script);
 }))
});

src/mf-remotes.js

Для запроса можем воспользоваться функцией fetch(), например, за тегом последней сборки. Затем внутри callback формируем объект с интерфейсом get/init и возвращаем его в Promise(), как и требуется в документации.

Из требований осталось упростить описание нескольких удалённых модулей. Создадим массив с объектами, у которых будут только tag и url

const remoteModules = [
 {
   tag:'app2-tag-name',
   url:'https://localhost:4002'
 },
]
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "app1",
remotes: getRemoteModules(remoteModules)
}),
…
]
…
}

src/webpack.config.js

Массив передадим в функцию getRemoteModules(), которая сформирует объект для запроса удалённый модулей. Можно использовать reduce(), чтобы преобразовать массив в объект. Ключ содержит название удалённого модуля, а значение — тело функции с Promise, которое формировали ранее.

module.exports = getRemoteModules = (modules) =>(
 modules.reduce((object, remoteModule) => {
   const remoteName = remoteModule.tag.split('-').join('_');
   return {
     ...object,
     [remoteName]: promise ${getRemoteModule(remoteName, remoteModule.url)},
   }
 }, {})
)

src/mf-remotes.js 

Мы выполнили все требования. На этапе сборки Webpack будет сформировано строковое представление для каждого удалённого модуля. В момент импорта удалённого модуля динамически выполнится скрипт с Promise, и он загрузит содержимое модуля. 

Схема решения

На схеме ниже показана взаимосвязь host-приложения с удалённым модулем для статического и динамического адреса.

Статический адрес
Статический адрес
Динамический адрес
Динамический адрес

Module Federation в Delivery Club

В Delivery Club мы используем Module Federation для проекта личного кабинета ресторанов. Разделы личного кабинета — это удалённые модули, которые содержат веб-компоненты со своей маршрутизацией и хранилищем. В данном случае host-проектом выступает оболочка с базовыми возможностями:

  • авторизация пользователя;

  • настройки профиля;

  • sidebar c навигацией. 

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

Примерная схема проекта личного кабинета для ресторанов, который использует Module Federation:

Схема проекта личного кабинета для ресторанов
Схема проекта личного кабинета для ресторанов

Выводы

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

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

Также рекомендую посмотреть в репозитории Module Federation примеры плагина на все случаи жизни, например, реализацию с любыми фреймворками.

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии12

Публикации

Информация

Сайт
tech.delivery-club.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Yulia Kovaleva