Аннотация

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

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

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

Начнем!

Введение

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

Ниже перечислены некоторые из сценариев, в которых может быть полезно использовать Module Federation:

  1. Микросервисная архитектура: Если у вас есть несколько микросервисов, которые должны работать вместе, то вы можете использовать Module Federation для совместного использования кода между этими сервисами.

  2. Разделение приложений на части: Если ваше фронтенд-приложение слишком большое, и вам нужно разделить его на несколько независимых частей, то Module Federation может быть очень полезен.

  3. Динамическая загрузка кода: Если вы хотите улучшить производительность вашего приложения, загружая только тот код, который действительно необходим пользователю на данной странице, то Module Federation может быть полезен.

  4. Совместное использование компонентов: Если у вас есть несколько приложений, которые используют общие компоненты, то Module Federation может позволить им совместно использовать эти компоненты.

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

Внедрение Module Federation

Для того чтобы использовать технологию Module Federation, нужно воспользоваться соответствующим плагином — ModuleFederationPlugin.

Конфиги будут написаны для webpack, но данная технология поддерживается и в vite, и во многих других сборщиках.

Давайте посмотрим на код конфигов хостового приложения и дочернего.

Хостовое приложение:

const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {  
  entry: "./src/index.js",
  output: {    
    path: path.resolve(__dirname, "dist"),    
    filename: "bundle.js",  
  },  
  plugins: [ 
    new ModuleFederationPlugin({
      name: "host", 
      remotes: { 
        remote: "remote@http://localhost:3001/remoteEntry.js",
      },
      shared: ["react", "react-dom"],
    }), 
  ],
};

В этом конфиге создается хостовое приложение с именем “host” и добавляется удаленное дочернее приложение с именем “remote” и указывается его URL-адрес. Также указываются общие зависимости (подробнее про параметр shared будет ниже), которые необходимо совместно использовать с дочерним приложением.

Дочернее приложение:

const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {  
  entry: "./src/index.js",  
  output: {    
    path: path.resolve(__dirname, "dist"),    
    filename: "bundle.js",    
    publicPath: "http://localhost:3001/",  
  },  
  plugins: [ 
    new ModuleFederationPlugin({ 
      name: "remote",    
      exposes: {    
        "./Button": "./src/Button", 
      },      
      shared: ["react", "react-dom"],  
    }),  
  ],
};

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

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

Архитектура

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

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

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

  3. Архитектура с общими библиотеками — при использовании module federation можно легко создать общие библиотеки, которые будут использоваться несколькими приложениями. В этом случае библиотеки могут быть выделены в отдельный модуль и затем использоваться как в хостовом, так и в дочерних приложениях.

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

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

В формате markdown изображено как это выглядит:

Пример архитектуры
Пример архитектуры

Использование модулей в хостовом приложении

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

// название в импорте соответствует названию в конфиге хостового приложения

import("remote").then((module) => {
  // использование экспорта модуля
}).catch((err) => { 
    console.log("Ошибка загрузки удаленного модуля", err);
  });

В этом примере мы используем функцию import() для загрузки удаленного модуля по его пути относительно корня дочернего приложения. После успешной загрузки модуля, мы можем использовать его экспорты внутри блока then(). Если при загрузке модуля произошла ошибка, мы ловим ее в блоке catch().

Также, можно использовать функцию remote, предоставляемый плагином ModuleFederationPlugin, для импорта удаленных модулей в более простой форме:

const remoteModule = remote("remote", "./path/to/remote/module");

// использование экспорта удаленного модуля

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

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

Общие зависимости (shared параметр)

Теперь подробнее остановимся на общих зависимостях.

Параметр shared в Module Federation позволяет совместно использовать модули между разными приложениями, чтобы избежать дублирования кода и уменьшить размер бандлов.

Пример конфигурации для приложения, которое экспортирует модуль react:

new ModuleFederationPlugin({
  name: "app1",   
  filename: "remoteEntry.js",   
  exposes: {     
    "./Button": "./src/Button",      
  },     
  shared: {   
    react: {       
      singleton: true,  
    },  
  }, 
}),

То же самое с хостовым приложением:

new ModuleFederationPlugin({    
  name: "app2",      
  filename: "remoteEntry.js",  
  remotes: {      
    app1: "app1@http://localhost:3001/remoteEntry.js", 
  },     
  shared: {      
    react: {    
      singleton: true,       
    },      
  },   
}),

В этом примере мы определили общий модуль react и указали его как singleton, что означает, что только одна копия модуля будет использоваться между приложениями.

Затем в коде приложения, которое использует общий модуль, можно импортировать его, используя синтаксис модуля:

import React from 'react';

При сборке приложения webpack будет искать модуль react сначала в локальных зависимостях, а затем в общих модулях, которые определены в списке shared для каждого приложения.

Вот примеры некоторых дополнительных параметров, которые можно использовать с shared модулями:

  • singleton: если установлено значение true, то общий модуль будет загружаться только один раз, даже если он используется в нескольких приложениях.

  • requiredVersion: определяет минимальную версию общего модуля, которая должна быть загружена.

  • import: можно использовать для указания пути к общему модулю, если он не является глобальной перемен

  • eager: если установлено значение true, то общий модуль будет загружен сразу при инициализации приложения, а не по требованию.

  • lazy: если установлено значение true, то общий модуль будет загружен только по требованию.

  • strictVersion: если установлено значение true, то при загрузке общего модуля будет проверяться его версия, и если она не соответствует требуемой, то загрузка завершится ошибкой.

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

import("./bootstrap").then(() => { 
  // Code that depends on the dynamically loaded module
});

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

Таким образом, параметр shared в module federation позволяет совместно использовать модули между разными приложениями, что упрощает разработку и уменьшает размер бандлов.

В Module Federation решение о том, какую зависимость использовать, принимается на основе имени модуля и версии, указанных в конфигурации. Когда дочернее приложение запрашивает модуль из хостового приложения, хостовое приложение проверяет, есть ли у него модуль с таким именем и версией. Если есть, то хостовое приложение отправляет этот модуль в ответ на запрос дочернего приложения. Если модуль с таким именем и версией отсутствует, хостовое приложение может:

  • Загрузить и инициализировать необходимый модуль, если это возможно;

  • Отправить ответ с ошибкой, если модуль недоступен.

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

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

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

module.exports = { 
  //...  
  plugins: [ 
    new ModuleFederationPlugin({   
      //...     
      fallback: {    
        // имя источника и конфигурация для fallback    
        "shared-dependency": {    
          // загружать модуль из этого источника       
          eager: true,       
          import: "https://cdn.example.com/shared-dependency.js",   
          // указать глобальную переменную, которую этот модуль экспортирует  
          // это необходимо для того, чтобы модуль можно было импортировать        
          // в дочернем приложении так же, как и другие модули     
          shareKey: "shared-dependency",       
          shareScope: "default",    
          singleton: true     
        }    
      }  
    })  
]}

В этом примере указывается, что если модуль с именем “shared-dependency” не найден в хостовом приложении, он будет загружен из источника https://cdn.example.com/shared-dependency.js. Мы также указали, что этот модуль экспортирует глобальную переменную с именем “shared-dependency”, которая будет доступна в дочернем приложении для импорта модуля.

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

Использование MF в продакшен

Давайте теперь посмотрим, как выглядит конфиг для прода.

Хостовое приложение:

const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {  
  entry: "./src/index.js", 
  output: {   
    path: path.resolve(__dirname, "dist"),  
    filename: "bundle.js",  
  },  
  plugins: [   
    new ModuleFederationPlugin({ 
      name: "host",    
      remotes: {  
        // используем абсолютный путь     
        app1: "app1@https://example.com/app1/remoteEntry.js", 
        // используем относительный путь    app2: "app2@/app2/remoteEntry.js"     
      },   
    }),  
  ],
};

Дочернее приложение:

const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {  
  entry: "./src/index.js", 
  output: {    
    path: path.resolve(__dirname, "dist"),  
    filename: "bundle.js",  
    publicPath: "app1",  
  },  
  plugins: [  
    new ModuleFederationPlugin({  
      name: "app1",    
      exposes: {      
        "./Button": "./src/Button",     
      },   
      shared: {   
        react: {     
          singleton: true,  
        },   
      },    
    }), 
  ],
};

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

Также указывается параметр publicPath для дочернего приложения, чтобы webpack знал, где найти удаленные модули. Если мы не указываем этот параметр, Webpack будет использовать относительный путь для поиска удаленных модулей, что не будет работать, если разместить дочерние приложения на разных доменах.

Написание nginx конфига

Для простоты можно разместить в файловой структуре на сервере все дочерние приложения внутри хостового:

Файловая структура на сервере
Файловая структура на сервере

И тогда необходимо раздать только один файл — хостовой index.html

server {    
  listen 80;    
  server_name example.com; 
  location / {
    root /path/to/host-app;   
    index index.html;      
    try_files $uri /index.html;  
  }
}

Здесь мы всегда получаем хостовой index.html, загружаем хостовое приложение, которое, в свою очередь, загружает дочерние remoteEntry, когда это необходимо (при переходе в приложение).

Решение проблем

  1. Самая популярная проблема — shared зависимости. С первого раза может не получится всё настроить, возникают проблемы чаще всего, когда версии пакетов не совпадают, поэтому нужно использовать только одну версию того или иного пакета. Также бывают ситуации, когда библиотека может загрузиться позже, чем она будет использована в проекте, и соответственно, это приводит к ошибкам. Чтобы решить эту проблему, нужно установить параметр eager в значение true, тогда библиотека будет загружаться вместе с инициализацией приложения.

  2. Также, при использовании module federation с несколькими приложениями могут возникать коллизии в хешах чанков вебпака. Это происходит из-за того, что каждое приложение может использовать свой алгоритм генерации хешей. Одним из способов решения этой проблемы является установка параметра uniqueName для каждого конкретного entry в конфигурации вебпака. Это приведет к тому, что все хеши будут вычислены на основе этого имени, а не на основе полного пути к чанку. Например:

module.exports = { 
  //...  
  entry: { 
    app1: {   
      import: './src/index.js',    
      filename: 'app1.js',     
      uniqueName: 'app1',    
    },   
    app2: {     
      import: './src/index.js',   
      filename: 'app2.js',    
      uniqueName: 'app2', 
    },    
    //...  
  },
    //...
};

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

const { HashedModuleIdsPlugin } = require('webpack');

module.exports = { 
  //... 
  plugins: [  
    new HashedModuleIdsPlugin(),  
    //...  
  ], 
  //...
};

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

3. Разбиение на чанки: когда только изучался вопрос про module federation, нигде не было сказано (либо плохо смотрели), что module federation использует автоматическое разбиение на чанки, поэтому, при попытке самостоятельно настроить алгоритм разбиения на чанки, приложение падало с ошибками.

Поэтому, следует сказать, что webpack при использовании module federation разбивает код на чанки автоматически, в зависимости от того, какие модули необходимо загрузить для каждого из приложений.

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

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

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

Поэтому, чтобы корректно всё выполнялось, необходимо указывать в optimization следующее:

module.exports = {  
  //... 
  optimization: {    
    splitChunks: false, 
  },  
  //...
};

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

Заключение

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

Спасибо за внимание!

Источник: https://webpack.js.org/concepts/module-federation/

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


  1. lorus
    00.00.0000 00:00
    +1

    А чем это лучше продуманного разделения приложения на модули? Или это для тех, кто не смог?


    1. BoumRZ Автор
      00.00.0000 00:00

      Имеешь в виду без использования module federation? А как подключать модули друг к другу тогда?


      1. lorus
        00.00.0000 00:00

        Эммм, package.json dependencies + import?


        1. BoumRZ Автор
          00.00.0000 00:00

          Нуу, считай уже это не микрофронт) С таким подходом не удобно работать с lazy loading, библиотеки придется импортить в каждый конкретный модуль, а с помощью MF их можно передать дочернему приложению, плюс версионирование вроде как нельзя автоматическое настроить: когда залил новую версию модуля, нужно в использующем приложении либо руками поменять, либо как-то скриптом дернуть


          1. lorus
            00.00.0000 00:00
            -1

            Микрофронт != MF.
            Там делов сделать сборку на том же Rollup - вообще мизер. Не вижу проблем вообще.


            1. BoumRZ Автор
              00.00.0000 00:00

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


      1. lorus
        00.00.0000 00:00
        +2

        Я не работаю с module federation. А с чудовищем WebPack завязал лет 10 назад. Отсюда непонимание.

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

        Тут я вижу два варианта:

        1. Приложения собираются независимо. Значит хостовое приложение - просто CDN, который хостит разделяемые модули. Но это легко реализуется без module federation.

        2. Приложения собираются вместе и хостовое приложение делает tree-shaking, например, и хостит только те части кода (чанки), которые реально используется приложениями. Но, во-первых, это опять-же реализуется без использования module federation. Достаточно всю сборку делегировать в хостовое приложение. А во-вторых, как быть с развёртыванием? Ведь если обновить дочернее приложение, но не обновить хостовое (или наоборот), то приложение просто сломается. То есть обновлять их нужно одновременно, что "не очень", мягко говоря.

        Я чего-то недопонимаю, наверное.


        1. lucius
          00.00.0000 00:00

          Поддержу вопрос и добавлю еще один, так как я тоже не понимаю преимуществ MF.

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

          Как подключать лучше репы друг к другу, если нужно использовать не только бандл, но и код? Прописывать в `package.json` ссылки чтобы `node_modules` вытягивались? Или делать в гите вложенные репы или submodules?


          1. lorus
            00.00.0000 00:00

            В данном случае лучше монорепозиторий, а не две отдельные репы.

            Ещё можно публиковать собранные пакеты в NPM (приватно). Поддерживается GitHub. Или можно поднять копоративный Verdaccio. Но монорепозиторий проще.


            1. lucius
              00.00.0000 00:00

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


              1. lorus
                00.00.0000 00:00

                Вот тут предлагают публиковать type definitions рядом с кодом, через MF: https://spin.atomicobject.com/2022/07/19/typescript-federated-modules/


          1. BoumRZ Автор
            00.00.0000 00:00

            Вот у нашей команды и стояла цель: разбить большое приложение на модули. И мы успешно перешли на MF. Тем более module federation есть не только в вебпак, энтузиасты для роллапа его сделали


        1. BoumRZ Автор
          00.00.0000 00:00

          Обновлять одновременно не надо. Обновил дочернее, хвостовое это сразу подтянуло и ничего не сломалось.

          Все это можно сделать и без MF, конечно, но MF предоставляет удобные инструменты, которые позволяют не думать о том, как развернуть, как управлять либами и так далее


          1. lorus
            00.00.0000 00:00

            То есть сборка чанков происходит динамически, во время выполнения? В статье, к сожалению, сам механизм не описан.


            1. BoumRZ Автор
              00.00.0000 00:00

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

              И мы сами можем рулить в какой момент какой модуль брать

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


  1. BoumRZ Автор
    00.00.0000 00:00

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

    Однако судя по комментам, стоило привести сравнение, возможно сделаю это в отдельной статье


  1. jakobz
    00.00.0000 00:00

    Непонятно зачем они изобрели еще один формат модулей. Тоже самое делал и require.js, а сейчас - es6-модули, или systemjs.


    1. BoumRZ Автор
      00.00.0000 00:00

      requirejs не все может. Если нужна поддержка es6 или разные форматы модулей в одном проекте, то es6-модули и systemjs соответственно


      1. jakobz
        00.00.0000 00:00

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

        Мы именно так микрофронты и делаем, без всяких module federation.

        Или я не вижу каких-то прям фичей, которые module federation дает сверх обычных модулей?


        1. BoumRZ Автор
          00.00.0000 00:00

          MF просто помогает проще все контролировать и управлять этим