Информация в чистом виде - это не знание. Настоящий источник знания - это опыт.

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

Небольшая предыстория, для чего написана эта статья

Погрузившись в работу с Module Federation, я столкнулся с такой проблемой, как отсутствие информации для продвинутых разработчиков. Большинство информации, что я встречал была либо про такую технологию как React, либо Angular. Но примеров с Vue как таковых я не нашел, только самые простые, что конечно же, для продвинутых не катит :) Поэтому пришлось разобраться во всем самому и спустя тысячи проб и ошибок, я наконец то доделал данную задачу и готов рассказать, что же такого волшебного кроется за динамическим импортом во Vue.

Вкратце о Module Federation

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

Основные принципы Module Federation:

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

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

  3. Обмен данными и функциональностью: Модули могут обмениваться данными и предоставлять свою функциональность другим модулям. Это позволяет создавать гибкие и расширяемые приложения.

  4. Управление зависимостями: Module Federation позволяет явно управлять зависимостями между модулями. Каждый модуль может указывать, какие модули и версии он требует для своей работы.

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

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

Поэтапно напишем Host и Remote приложения

Задумка следующая:
Host - делится своим компонентом Content и будет лежать он на порту 3002. Далее запускаем Remote приложение, ждем пока пользователь введет нужный порт в инпут, далее подгружаем компонент, если такой существует. Profit!

Немного конфигурации:
1) webpack.config.js - описывать в принципе нечего, базовая структура для module federation plugin

...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
    new ModuleFederationPlugin({
      name: 'home',
      filename: 'remoteEntry.js',
      exposes: {
        './Content': './src/components/Content',
      },
      shared: {
        vue: {
          singleton: true,
        },
      },
    }),
...
});

2) Content.vue:

<template>
  <div style="color: #d9c1e4;">{{ title }}</div>
</template>
<script>
export default {
  data() {
    return {
      title: "Remote content component",
    };
  },
};
</script>

3) App.vue

<template>
  <main class="main">
    <h3>Host App</h3>
    <Content />
  </main>
</template>

<script>
import { ref, defineAsyncComponent } from "vue";
export default {
  components: {
    Content: defineAsyncComponent(() => import("./components/Content")),
  },
  setup() {
    const count = ref(0);
    const inc = () => {
      count.value++;
    };

    return {
      count,
      inc,
    };
  },
};
</script>

<style>
/* Немного стилей */
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');

img {
  width: 200px;
}
h1 {
  font-family: Arial, Helvetica, sans-serif;
}

html,body {
  margin: 0;
}

h3 {
  margin: 0;
  color:#d9c1e4;
}

.main{
  height: 100vh;
  background: gray;
  display: flex;
  flex-direction: column;
  justify-content: center;
  font-family: 'Montserrat', sans-serif;
  align-items: center;
  color: #fff;
}
</style>

4) Настройки для package.json:

"scripts": {
    "start": "webpack-cli serve",
    "serve": "serve dist -p 3002",
    "build": "webpack --mode production",
    "clean": "rm -rf dist"
  },

На этом наш хост готов к использованию. Нужно только подхватить 3002 порт и должным образом обработать.

Теперь конфигурация для Remote приложения:

1) webpack.config.js:

...
    new ModuleFederationPlugin({
      name: 'layout',
      filename: 'remoteEntry.js',
      exposes: {},
      shared: {
        vue: {
          singleton: true,
        },
      },
    }),
...

2) Layout.vue. Тут я разберу немного подробнее, т.к. в этом компоненте находятся ключевые функции для работы программы. В чем заключается алгоритм на данный момент:

  • Есть инпут, с привязанной к нему переменной port

  • Введя порт можем нажать на кнопку, по которой запускается функция, подхватывающая тот manifest по введенному порту

  • Пытаемся создать скрипт из этого манифеста и подсоединить его к нашему приложению

  • Как только скрипт загрузился - можем забрать оттуда объект и прикрутить его к динамическому компоненту

3) package.json:

 "scripts": {
    "start": "webpack-cli serve",
    "serve": "serve dist -p 3001",
    "build": "webpack --mode production",
    "clean": "rm -rf dist"
  },

Перейдем к коду:

Форма с инпутом выглядит следующим образом:

<div class="component">
    <p style="font-size:22px; margin-bottom: 5px">Layout App</p>
    <div class="form">
    <label>Enter port for loading</label>
    <input type="text" v-model="port">
    <button @click="getRemoteComponent">Get remote component</button>
</div>

В принципе, осталось написать функцию getRemoteComponent и готово. Опишем тело функции:

// Для начала зададим конфигурацию для запроса 
const uiApplication = {
   protocol: 'http',
   host: 'localhost',
   port: this.port,
   fileName: 'remoteEntry.js'
}
// Теперь построим ссылку
const remoteURL = `${uiApplication.protocol}://${uiApplication.host}:${uiApplication.port}/${uiApplication.fileName}`;

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

const moduleScope = 'home' // Переменные для дальнейшей конфигурации
const moduleName = 'Content'

const element = document.createElement('script');
element.type = 'text/javascript';
element.async = true;
element.src = remoteURL;

Если произошла ошибка, обработаем ее следующим образом:

element.onerror = () => {
    alert(`Port ${this.port} doesn't have any content! Try another`)
}

Если же скрипт успешно загрузился, то можем его обработать, но для его написания потребуется еще одна функция, которая есть в документации webpack'a:

async loadModule(scope, module) {
    await __webpack_init_sharing__('default');
    const container = window[scope];
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  }

Данная функция возвращает объект вида:

Возвращенное значение loadModule
Возвращенное значение loadModule

Невооруженным глазом видно, что это какая то штука, относящаяся к компоненту, но вот что с ней делать? Ответ прост - передадим этот объект динамическому компоненту из Vue и оно магическим образом соберет этот компонент!

Теперь все таки вернемся к обработке скрипта:

element.onload = () => {
    const remoteComponent = this.loadModule(moduleScope, `./${moduleName}`)
    remoteComponent.then(res => {
      console.log(res.default);
      this.dynamicComponent = res.default; 
    })
};
document.head.appendChild(element);

Вот и все! Наша работа на этом закончена, осталось только приписать этот объект в компонент следующим образом:

<div class="component">
    <p style="font-size:22px; margin-bottom: 5px">Remote App</p>
    <component :is="dynamicComponent"></component>
</div>

Таким образом задача решена, можем радоваться :)
Итоговый код компонента Layout.vue:

<template>
  <div class="main">
    <div class="component">
      <p style="font-size:22px; margin-bottom: 5px">Layout App</p>
      <div class="form">
        <label>Enter port for loading</label>
        <input type="text" v-model="port">
        <button @click="getRemoteComponent">Get remote component</button>
      </div>
    </div>
    <div class="component">
      <p style="font-size:22px; margin-bottom: 5px">Remote App</p>
      <component :is="dynamicComponent"></component>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      port: null,
      dynamicComponent: null
    }
  },
  methods: {
    getRemoteComponent() {
      console.log(this.port, '<- Подгружаем по порту')

      // Можно конфигурировать любые параметры динамически
      const uiApplication = {
        protocol: 'http',
        host: 'localhost',
        port: this.port,
        fileName: 'remoteEntry.js'
      }

      const remoteURL = `${uiApplication.protocol}://${uiApplication.host}:${uiApplication.port}/${uiApplication.fileName}`;
      console.log(remoteURL)

      const moduleScope = 'home'
      const moduleName = 'Content'
      const element = document.createElement('script');
      element.type = 'text/javascript';
      element.async = true;
      element.src = remoteURL;

      element.onload = () => {
        const remoteComponent = this.loadModule(moduleScope, `./${moduleName}`)
        remoteComponent.then(res => {
          console.log(res.default);
          this.dynamicComponent = res.default;
        })
      };

      element.onerror = () => {
        alert(`Port ${this.port} doesn't have any content! Try another`)
      }

      document.head.appendChild(element);
    },
    async loadModule(scope, module) {
      await __webpack_init_sharing__('default');
      const container = window[scope];
      await container.init(__webpack_share_scopes__.default);
      const factory = await window[scope].get(module);
      const Module = factory();
      return Module;
    }
  }
};
</script>

<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');

* {
  font-family: 'Montserrat', sans-serif;
  color:#fff;
}

body, p {
  margin: 0;
}

.main {
  height: 100vh;
  display: flex;
  background: gray;
}

.component {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  border: 2px solid #ffff;
  padding: 5px;
  border-radius: 10px;
  width: 100%;
}

.form {
  display: flex;
  max-width: 300px;
  flex-direction: column;
}

input {
  margin: 10px 0;
  color:black;
}

button {
  color: black;
}

</style>

Результат получился следующий (напомню, что хост раздает компонент по 3002 порту):

Финал темы
Финал темы


Весь исходный код можете посмотреть на моем гитхабе

На этом данная статья окончена, надеюсь она была полезна для вас!

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


  1. serginho
    12.07.2023 19:13

    Не знаю что там в вебпаке, но в esbuild можно сделать module federation просто заменив импорты в начале страницы на импорты в коде типа:

    const someLib = await import('some-lib');