Правильные ли у вас микрофронты?
- понедельник, 18 ноября 2024 г. в 00:00:05
Рассмотрю на примере nx.dev и webpack module federation.
nx.dev был выбран для того, чтобы не пришлось самостоятельно придумывать решения, а взять готовые, которые могут пригодиться при работе с микрофронтами. Можно также yarn workspaces использовать, но тогда бы пришлось все необходимые скрипты писать самому.
По самом nx.dev, писал когда-то статью, можно почитать тут. Некоторые моменты могли устареть, но сама концепция осталсь та же. Так например package-based проектов уже нет.
Однако в мире микрофронтендов есть два ключевых фактора, без которых вся их польза сведется к нулю:
Независимые команды разработки. Каждая команда должна иметь свои микрофронты.
Крупный проект с независимыми подсистемами. Каждую из таких подсистем можно оформить как отдельный микрофронт.
Микрофронтенды могут создавать сложности как для 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. Это показатель того, что вы что-то делаете не так.
Если ваше приложение выступает как библиотека и она будет только использоваться фронтенд проектами, то необходимо явно указать, что вам нужно только 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`
}
})
Контакты (Contacts)
Задачи (Tasks)
Сделки (Deals)
Аналитика (Analytics)
Профиль пользователя (User Profile)
Структура проекта будет выглядеть так:
my-workspace/
├── apps/ # в каждом микрофронтенде будет своя бизнес логика
├───────contacts/
├───────tasks/
├───────deals/
├───────analytics/
├───────user/
├── libs/
├───────ui/ # тупые компоненты, которые будут переиспользоваться
├───────utils/ # можем положить хуки или другие переиспользуемые ф-ции
├───────shared/
├─────────────contacts # часть данных из contacts положили сюда,
# чтобы напрямую использовать в других микрофронтендах
# файл маршрутов например.
В папке apps у нас по сути независимые приложения находятся, т.е. наши микрофронты.
По разделению кода более подробно можно почитать на сайте nx.dev и частично взять концепцию fsd, чтобы микрофронты не превращались в месиво.
Я в кратце затронул проблему правильной организации микрофронтендов, но как показывает практика, находятся проекты, где не умеют готовить их. Они придумывают собственные неоптимальные велосипеды, мучают себя, разработчиков, девопсов и кто пользуется этим продуктом. И самое интересное, что эти "костыли" придумывают люди, которые занимают позицию архитектора, тех. лида.
Идеальная работа с микрофронтами должна быть такая, что мы в рамках одной задачи мы можем поменять код любого микрофронта или библиотеки и наш динамический ci/cd решит проблему за нас какие проверки запустить и какие микрофронты нужно переразвернуть.
Разработчик не должен страдать с созданием дополнительных веток под каждый микрофронтенд и тем более публиковать изменения. А то получается, что первопричина изменений это не задача поставленная бизнесом, а "особенности" которые усложняют нам жизнь.
Микрофронты — это не универсальное решение. Их оправданность зависит от масштаба проекта и структуры команд. Если у вас небольшой проект или вся команда работает над одним кодом, микрофронты усложнят жизнь.