Рассмотрю на примере 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 решит проблему за нас какие проверки запустить и какие микрофронты нужно переразвернуть.

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

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