javascript

Правильные ли у вас микрофронты?

  • понедельник, 18 ноября 2024 г. в 00:00:05
https://habr.com/ru/articles/859230/

Рассмотрю на примере 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"),
  },
};

Допустим у вас есть метод для загрузки микрофронтендов loadRemote.js

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

function App() {
  const [RemoteComponent, setRemoteComponent] = useState(null);

  useEffect(() => {
    loadRemoteModule("http://localhost:3001", "remoteApp", "./Widget")
      .then((module) => setRemoteComponent(() => module.default))
      .catch((error) => console.error("Error loading remote module:", error));
  }, []);

  return (
    <div>
      <h1>Host Application</h1>
      {RemoteComponent ? <RemoteComponent /> : <p>Loading remote component...</p>}
    </div>
  );
}

export default App;

Строчка loadRemoteModule("http://localhost:3001", "remoteApp", "./Widget") берется не из потолка, мы взяли из remote приложения, адрес приложения, у нас указан порт 3001, название - remoteApp, в ModuleFederationPlugin это name и сам компонент в exposes.

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

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

function App() {
  const [RemoteComponent, setRemoteComponent] = useState(null);

  useEffect(() => {
    loadRemoteModule("http://localhost:3001", "remoteApp", "./Widget")
      .then((module) => setRemoteComponent(() => module.default))
      .catch((error) => console.error("Error loading remote module:", error));
  }, []);

  return (
    <div>
      <h1>Host Application</h1>
      {RemoteComponent ? <RemoteComponent /> : <p>Loading remote component...</p>}
    </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 решит проблему за нас какие проверки запустить и какие микрофронты нужно переразвернуть.

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

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