Рассмотрю на примере nx.dev и webpack module federation.

nx.dev был выбран для того, чтобы не пришлось самостоятельно придумывать решения, а взять готовые, которые могут пригодиться при работе с микрофронтами. Можно также yarn workspaces использовать, но тогда бы пришлось все необходимые скрипты писать самому.

По самом nx.dev, писал когда-то статью, можно почитать тут. Некоторые моменты могли устареть, но сама концепция осталсь та же. Так например package-based проектов уже нет.

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

  1. Независимые команды разработки. Каждая команда должна иметь свои микрофронты.

  2. Крупный проект с независимыми подсистемами. Каждую из таких подсистем можно оформить как отдельный микрофронт.

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

Микрофронтенды позволяют независимым командам работать над отдельными подсистемами, сохраняя общую консистентность. Но так ли легко внедрить микрофронты и получить от них реальную пользу?

Проблемный проект

Публикуемые библиотеки (или еще одна дополнительная явная зависимоть)

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

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

Примерная структура проекта:

my-workspace/
├── apps/                # Приложения (frontend, backend, mobile и др.)
├── libs/                # Библиотеки (переиспользуемые модули, UI-компоненты и др.)
├── dist/                # Результаты сборки
├── nx.json              # Глобальная конфигурация Nx
├── package.json         # Общие зависимости монорепозитория

В свое время мне помогла эта статья, чтобы gitlab-ci создавался динамически и туда попадали микрофронты, которые требуется обновить.

Как оперативно подгружать зависимости без публикации

Пакеты также могут быть связаны через soft link пакетного менеджера, но если у них одна область — это префикс перед именем пакета, тогда будет пересоздавать папку, этот вариант не подходит, если несколько проектов в одной области. "@my-corp/a", "@my-corp/b"

Временное решение - создание symlink операционной системы через ln -s <source_file> <link_name>, но они будут удалены после того когда зависимость из package.json будет удалена или добавлена.

Еще как вариант указание в package.json зависимости, которая локально расположена также:

"dependencies": {
  "my-local-package": "file:../path/to/local/package"
}

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

Как вариант использовать yarn workspaces в package.json указываем:

{
  "workspaces": ["apps/*", "libs/*"]
}

В этом случае он за нас создает символические ссылки в node_modules.

Либо в файле tsconfig.js указываем пути (nx.dev по сути тоже самое делает при создании новой библиотеки и микрофронта):

{
  "paths": {
    "@app/app1": ["apps/app1/index.ts"],
    "@lib/utils": ["libs/utils/index.ts"]
  }
}

Здесь мы указываем конкретный файл, чтобы не была возможность импортировать "наружу" те части приложения, которые не хотим. Но в данном случае может быть чуточку долго сборка, т.к. мы из исходников собирать будем, а не из скомпилированных файлов зависимости. Но при таком выборе есть плюс - более точная карта исходного кода (sourcemap).

Статичный импорт

Нет смысла между микрофронтами статистический импорт применять, так весь смысл микрофронтов убивается, т.к. у вас микрофронты собираются и в каждый финальный бандл поместится тот участок кода, которые импортируете. Это хорошо работает с libs, когда микрофронты в apps импортируют их. Динамическая загрузка микрофронтов для webpack описана здесь. React-lazy в данном случае нам не подойдет, т.к. он создает chunk внутри проекта, а каждый микрофронтенд на своем адресе должен размещаться и загружать от туда необходимые ресурсы.

Примерный файл webpack.config.js remote приложение:

const { ModuleFederationPlugin } = require("webpack").container;
const packageJson = require("./package.json");
const path = require("path");

module.exports = {
  entry: "./src/index",
  mode: "development",
  output: {
    publicPath: "http://localhost:3001/",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remoteApp",
      filename: "remoteEntry.js",
      exposes: {
        "./Widget": "./src/components/Widget",
      },
      shared: {
        react: { singleton: true, eager: true, requiredVersion: packageJson.dependencies.react},
        "react-dom": { singleton: true, eager: true, requiredVersion: packageJson.dependencies["react-dom"] },
      },
    }),
  ],
  devServer: {
    port: 3001,
    static: path.join(__dirname, "dist"),
  },
};

Примерный файл host приложения:

const { ModuleFederationPlugin } = require("webpack").container;
const packageJson = require("./package.json");
const path = require("path");

module.exports = {
  entry: "./src/index",
  mode: "development",
  output: {
    publicPath: "http://localhost:3000/",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      shared: {
        react: { singleton: true, eager: true, requiredVersion: packageJson.dependencies.react},
        "react-dom": { singleton: true, eager: true, requiredVersion: packageJson.dependencies["react-dom"] },
      },
    }),
  ],
  devServer: {
    port: 3000,
    static: path.join(__dirname, "dist"),
  },
};

Функция для загрузки модуля средствами webpack, файл loadComponent.js, мы его вызываем только когда у нас сам файл микрофронта загружен в память и мы извлекаем из window и инициализируем ("эта магия" взята из документации webpack)

export function loadComponent(scope, module) {
  return async () => {
    // Initializes the share scope. This fills it with known provided modules from this build and all remotes
    await __webpack_init_sharing__("default");
    const container = window[scope]; // or get the container somewhere else
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

Хук для загрузки микрофронтендов useDynamicScript.js, т.е. по сути скачивание javascript файла:

import React from "react";

export const useDynamicScript = (url) => {
    const [ready, setReady] = React.useState(false);
    const [failed, setFailed] = React.useState(false);
  
    React.useEffect(() => {
      if (!url) {
        return;
      }
   
      const element = document.createElement("script");
  
      element.src = url;
      element.type = "text/javascript";
      element.async = true;
  
      setReady(false);
      setFailed(false);
  
      element.onload = () => {
        setReady(true);
      };
  
      element.onerror = () => {
        setReady(false);
        setFailed(true);
      };
  
      document.head.appendChild(element);
  
      return () => {
        document.head.removeChild(element);
      };
    }, [url]);
  
    return {
      ready,
      failed
    };
  };

И сам файл компонент создадим который будет связывать DynamicModule.js:

import React from "react";
import { useDynamicScript } from './useDynamicScript';
import { loadComponent } from './loadComponent';

function DynamicModule(props) {
  const { ready, failed } = useDynamicScript(props.url);

  if (!ready) {
    return <h2>Загрузка микрофронтенда</h2>;
  }

  if (failed) {
    return <h2>Проблема с загрузкой микрофронтенда</h2>;
  }

  const Component = React.lazy(
    loadComponent(props.scope, props.module)
  );

  return (
    <React.Suspense fallback="Loading Module">
      <Component />
    </React.Suspense>
  );
}

export default DynamicModule;

Загружаем динамически микрофронтенд в хостовом приложении:

import React, { useState, useEffect } from "react";
import { loadRemoteModule } from "./utils/loadRemote";

function App() {
  return (
    <div>
      <h1>Host Application</h1>
      <DynamicModule url="http://localhost:3001/remoteEntry.js" scope="remoteApp" module="./Widget" />    
    </div>
  );
}

export default App;

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

Отсутствует адекватной архитектуры приложения

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

Вспоминаем нашу структуру проекта:

my-workspace/
├── apps/                # Приложения (frontend, backend, mobile и др.)
├── libs/                # Библиотеки (переиспользуемые модули, UI-компоненты и др.)

В папке apps у нас по сути независимые приложения находятся, т.е. наши микрофронты. В других проектах эта папка может называться как packages.

Т.к. у нас все микрофронты и их зависимости (libs) находятся в одной монорепозитории, у нас есть возможность вносить оперативно правки и не ждать пока кто-то релизнит стороннюю библиотеку, если вдруг мы бы выбрали подход не монорепозиторий, а микрорепозиторий.

И если нарушить принцип работы с монорепозиторием, например, под каждую ветку заносить свой "микрофронтенд", то у разработчиков будут большие проблемы, и в том числе усложняется поставка кода. Необходимо локально хранить по папке под каждый микрофронтенд.

branches
├── (pre-)ui-kit              # ui-kit
├── (pre-)utils               # utils
├── (pre-)site                # site

Да и при таком выборе у нас как минимум будет x2 веток, dev ветка pre-, и ветка релиза.

В данном случае это 6 веток. Если мы выбрали корректный подход, то веток было всего 2, pre-site и site, т.к. utils и ui-kit нет смысла выносить в отдельный микрофронтенды.

И этот подход разделения кода на микрофронтенды вызывает у разработчиков трудности, они пытаются чуть ли не каждый компонент положить в отдельный микрофронтенд, просто ужас! Это сколько сетевых запросов будет ?.

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

Костыльные скрипты

Чтобы поддерживать микрофронтенды, необходимо запускать их, а также производить различные манипуляции, создание нового микрофронта, развертывания приложений. Не факт что когда будете писать самостоятельные решения, у вас хватит компетенций реализовать это без побочных негативных эффектов. Поэтому одним из отличных решений - использовать nx.dev executor и generator, которые запускаются одной командой в консоли, либо расширением для vscode.

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

Корректная настройка package.json и vite.config

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

defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'index.ts'),
      name: 'vite-project',
      formats: ['es'],
      fileName: `index`
    }
  })

В package.json для фронтенд приложения можно указать (но не рекомендуется):

{
  "module": "dist/index.js",
  "types": "./dist/index.d.ts",
  "type": "module"
}

types используется typescript, чтобы разрешить проблему с типами, т.к. при импорте библиотеки мы использует js файл.

Но таким способом мы из проекта можем импортировать любые файлы, а нам это не хотелось бы делать. Корректным решением будет в package.json:

{
  "exports": {
    ".": {
      "import": "./dist/index.js"
    }
  }
}

Тогда у нас только единая точка входа файл ./dist/index.js, но при таком решении jest тесты не будут работать, т.к. они грузят commonjs версию, потому лучше оставить так:

{
  "exports": {
    ".": {
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js"
    }
  }
}

А файл vite.config.js удаляем format, оставляем по умолчанию es и umd.

defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'index.ts'),
      name: 'vite-project',
      fileName: `index`
    }
})

Пример CRM-системы с микрофронтами

Основные функциональные части (микрофронты):

  1. Контакты (Contacts)

  2. Задачи (Tasks)

  3. Сделки (Deals)

  4. Аналитика (Analytics)

  5. Профиль пользователя (User Profile)

Структура проекта будет выглядеть так:

my-workspace/
├── apps/                   # в каждом микрофронтенде будет своя бизнес логика
├───────contacts/
├───────tasks/
├───────deals/
├───────analytics/
├───────user/
├── libs/
├───────ui/                 # тупые компоненты, которые будут переиспользоваться
├───────utils/              # можем положить хуки или другие переиспользуемые ф-ции
├───────shared/
├─────────────contacts      # часть данных из contacts положили сюда,
                            # чтобы напрямую использовать в других микрофронтендах
                            # файл маршрутов например.

В папке apps у нас по сути независимые приложения находятся, т.е. наши микрофронты.

По разделению кода более подробно можно почитать на сайте nx.dev и частично взять концепцию fsd, чтобы микрофронты не превращались в месиво.

Выводы

Я в кратце затронул проблему правильной организации микрофронтендов, но как показывает практика, находятся проекты, где не умеют готовить их. Они придумывают собственные неоптимальные велосипеды, мучают себя, разработчиков, девопсов и кто пользуется этим продуктом. И самое интересное, что эти "костыли" придумывают люди, которые занимают позицию архитектора, тех. лида.

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

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

Микрофронты — это не универсальное решение. Их оправданность зависит от масштаба проекта и структуры команд. Если у вас небольшой проект или вся команда работает над одним кодом, микрофронты усложнят жизнь.

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


  1. rtatarinov
    17.11.2024 21:03

    Как у вас микрофронтенды общаются друг с другом? Потому что на бумаге оно все хорошо, что логика независимая, но по факту она становится зависимой часто! Берем примеры!

    1. У вас есть какой-нибудь баланс, который нужно обновить после того, как что-то произошло на странице в другом МФ.

    2. У вас есть куча лендингов, которые классно выносятся в МФ. А потом приходит бизнес и говорит хотим интерактивную форму на одном лендинге такую же, как в каком-нибудь МФе, чтобы пользователь мог прям тут на лендинге что-то поискать. Что будем с этим делать?


    1. ko22012 Автор
      17.11.2024 21:03

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

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

      Отвечая на второй вопрос, я уже упомянул, что мы можем общие переиспользуемые вещи выносить на уровень libs, эта концепция взята из nx.dev, можно почитать здесь.

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


      1. olku
        17.11.2024 21:03

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

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


        1. ko22012 Автор
          17.11.2024 21:03

          Разобрались с проблемой, оказывается в package.json поле name повторялось у двух микрофронтов, поэтому webpack думал что мы один и тот же проект пытаемся загрузить и он брал из кэша.


          1. olku
            17.11.2024 21:03

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

            Автор, спасибо за статью. Пишите ещё, хорошо бы и для новичков тоже, и бекендеров которые умеют в МСА но на беке. Микрофронты есть куда развивать. Один Module Federation, хрупкий и прожорливый, так себе вариант.


  1. jakobz
    17.11.2024 21:03

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

    То, что предлагают nx и webpack - это просто геморройный способ разложить тот же самый монолит по папочкам. Оно не дает никаких приемуществ над монолитным фронтом, порезанным на чанки.


    1. ko22012 Автор
      17.11.2024 21:03

      если у вас разные будут версии библиотек в микрофронтах, то могут быть проблемы с shared модулями, мы их указываем, чтобы в целом у нас единая система была. На nx.dev том же пишут, что нужно стараться использовать одну экосистему, не нужно angular, vue и react мешать. Это же и справедливо к актуальности библиотек используемых микрофронтами.

      А что касается монорепозитория, есть аналоги решения приведенного в статье - lerna и т.д.

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

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


      1. jakobz
        17.11.2024 21:03

        А я и не говорю, что монорепы плохо. Я говорю что если все части юзают одинаковые версии зависимостей и лежат в одной репе - то можно их просто разложить на под-папочки в /src. И получить тоже самое, что дают эти все module federation и nx.


        1. ko22012 Автор
          17.11.2024 21:03

          "разложить на под-папочки" можно по разному: монолит, многослойная, fsd, ddd, модульная.

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

          nx.dev это ведь инструмент для работы c монорепозиторием, а микрофронты это webpack module federation.


          1. jakobz
            17.11.2024 21:03

            Не буду спорить что такое «микрофронты», т.к. это придется опускаться на уровень Дяди Боба, и остальной псевдо-науки.

            Часто есть смысл в том, чтобы разделить веб-сайт на части так, чтобы можно было их независимо разрабатывать и отдельно деплоить. Часто (но не всегда) удобно разделение репозиториев. И, по определению, требуется чтобы можно было независимо обновлять зависимости. Иначе отдельно деплоить и разрабатывать - не выйдет. А вот эти все вебпаки и rx - не очень про это. Т.е. задачу разрабатывать и деплоить независимо - они не решают.

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

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

            И мне решительно непонятно зачем нужна module federation, когда есть стандартные es6-модули.


            1. ko22012 Автор
              17.11.2024 21:03

              module federation не имеет тех минусов, что есть у shadow dom. И опять вы приводите не корректное сравнение. Они решают разного рода задачи.