Рассмотрю на примере 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"),
},
};
Функция для загрузки модуля средствами 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-системы с микрофронтами
Основные функциональные части (микрофронты):
Контакты (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 решит проблему за нас какие проверки запустить и какие микрофронты нужно переразвернуть.
Разработчик не должен страдать с созданием дополнительных веток под каждый микрофронтенд и тем более публиковать изменения. А то получается, что первопричина изменений это не задача поставленная бизнесом, а "особенности" которые усложняют нам жизнь.
Микрофронты — это не универсальное решение. Их оправданность зависит от масштаба проекта и структуры команд. Если у вас небольшой проект или вся команда работает над одним кодом, микрофронты усложнят жизнь.
