Побудова незалежних застосунків з використанням мікрофронтендів. Переваги та недоліки різних рішень

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт, я Вадим Олійник, Front-End Tech Lead в компанії AMO. У цій статті я розповім про досвід побудови архітектури проєкту, розбитого на незалежні модулі з можливістю їх окремого деплою та масштабуванням розробки на декілька незалежних команд.

Також детально розкрию переваги та недоліки рішень, які ми обирали при проєктуванні.

Стаття поділена на пʼять розділів:

  1. З чого все починалось: вимоги до проєкту.
  2. Перша ідея з використанням npm-пакетів: переваги та недоліки.
  3. Мікрофронтенди як альтернативний підхід: переваги та недоліки.
  4. Вдосконалення підходу з мікрофронтендами: зміна способу комунікації та об’єднання застосунків в один репозиторій.
  5. Корисні поради щодо роботи з мікрофронтами та речі, на які варто звернути увагу.

Почнемо!

З чого все починалось: вимоги до проєкту

Приблизно рік тому ми розпочали новий проєкт з такими вимогами:

  • Потрібно було об’єднати кілька існуючих адмінпанелей в одну, звівши їх дизайн-систему до спільного вигляду.
  • Кожен користувач повинен мати доступ до певних адмінок в залежності від своєї ролі.
  • Кожна адмінпанель має деплоїтись незалежно, щоб над ними могли працювати команди з різними циклами деплою.
  • Хоча й передбачалося, що всі команди використовуватимуть React, ми також мали врахувати можливість створення нових застосунків на інших технологіях.

Далі я поділюся ідеями, які виникали на нашому шляху до реалізації цих вимог, а також розкажу про інструменти й технології, що нам у цьому допомогли.

Перш за все, розглянемо схему, на якій відображено загальний вигляд системи. Сірим кольором на ній позначено скелет сайту, який залишатиметься незмінним при переходах між сторінками. Ліворуч розміщено меню з можливістю обрати потрібну адмінпанель. Самі адмінпанелі позначені різними кольорами, щоб продемонструвати, що це незалежні застосунки. Окремі сторінки показані на схемі пунктирними лініями. Важливо зазначити, що одночасно на екрані може бути тільки одна адмінпанель, що значно спрощує нам життя — і це ми ще побачимо далі.

Перша ідея з використанням npm-пакетів: переваги та недоліки

Наша початкова ідея полягала у створенні окремого npm-пакету для кожної адмінпанелі з власним репозиторієм, процесом релізу та окремою командою розробників. Ці пакети мали бути об’єднані в кореневому застосунку (shell). Він відображав би скелет сайту та зберігав би глобальні дані, такі як інформація про сесію користувача або обрана кольорова тема.

Залежно від поточної сторінки, shell рендерив би відповідну адмінпанель, передаючи їй необхідні дані та підписуючись на її події. Також в окремий npm-пакет можна винести спільну для всіх застосунків логіку та стилі.

На схемах нижче детальніше зображено всі ці взаємодії:

Важливо зазначити, що в цьому задумі вкладені адмінки не залежать одна від одної. Вони інтегруються в кореневий застосунок і за потреби обмінюються даними з ним. Для цього shell передає параметри в функцію для рендеру потрібної адмінки, а та повертає необхідні дані для подальшої обробки в shell.

Для роботи з залежностями застосунків у цьому варіанті існує дві можливі стратегії:

  1. Якщо потрібно уникнути дублювання, можна не включати певні залежності до бандлу адмінки і тоді використаються версії цих залежностей з shell. Цей спосіб допоможе зменшити розмір бандлу, однак потрібно переконатись, що версії цих залежностей в shell сумісні з версіями, які використовуються у вкладених адмінках.
  2. Якщо потрібно використовувати різні версії залежностей, наприклад, для поступової міграції, необхідно залишити ці залежності в бандлі адмінки. Це призведе до збільшення розміру бандлу, проте забезпечить коректну роботу застосунку.

Тож вибирайте потрібну стратегію в залежності від вашого контексту.

Переваги такого підходу

Цей підхід задовольняє наші бізнес-вимоги. Зокрема, ми:

  • Забезпечили можливість роботи незалежної команди над кожним застосунком.
  • Створили кореневий застосунок, який відображає скелет сайту та може взяти на себе управління доступами юзера до адмінпанелей.
  • Врахували спільні елементи та їх шаринг між проєктами.
  • Будь-який застосунок може бути написаний не на React і він легко інтегрується в цю систему шляхом експорту функції для свого рендеру.
  • Здається ми навіть змогли забезпечити незалежний деплой кожного із застосунків, але насправді це не так...

Недоліки такого підходу

У поточній реалізації, при кожному деплої адмінки, необхідно вручну оновлювати відповідну версію в package.json shell, що робить процес деплою будь-якої адмінки залежним від деплою shell.

Ми зрозуміли, що варіант з інтеграцією вкладених застосунків в кореневий на етапі білда не підходить, бо це змушує нас синхронізувати їх деплої. Таким чином, нам потрібне інше рішення, про яке ми й поговоримо в наступних розділах.

Мікрофронтенди як альтернативний підхід: переваги та недоліки

Якщо ви будете шукати інформацію про незалежний деплой React застосунків, то швидко натрапите на статті про мікрофронтенди.

У загальних рисах ідею мікрофронтендів можна описати так: ми беремо монолітний застосунок, розділяємо його на менші, простіші для розуміння та розробки модулі, та будуємо залежність між ними. Кожен модуль розробляється та деплоїться незалежно один від одного.

Відмінність від нашого попереднього підходу полягає в тому, що ці модулі (мікрофронтенди) інтегруються в shell не на етапі білда, а вже у рантаймі, що забезпечує можливість незалежного деплою кожного з них.

Розгляньмо, як зміниться наша попередня схема з використанням цього підходу:

Таким чином, ми й надалі зберігаємо незалежність модулів, оскільки вони не взаємодіють між собою на будь-якому етапі та інтегруються в shell вже під час рантайму. І тепер, завдяки цьому, ми можемо деплоїти кожен модуль окремо без впливу на інші елементи системи.

Оскільки це повністю відповідає нашим вимогам, ми вирішили спробувати мікрофронтенди.

Детальніше про мікрофронтенди

Оскільки поняття мікрофронтендів є доволі розлогим, існує безліч підходів до їх реалізації. Це детально описано у статті з блогу Мартина Фавлера, де автор розповідає про недоліки та переваги кожного з цих підходів, в тому числі й нашої першої ідеї щодо інтеграції застосунків під час білду. Я рекомендую ознайомитися з цією статтею, якщо ви бажаєте детальніше зрозуміти тему мікрофронтендів.

Якщо говорити про готові рішення, то на сьогодні найпопулярнішими є Webpack5 Module Federation та Single-SPA. Розгляньмо їх детальніше та визначимо основні відмінності між ними.

Webpack 5 Module Federation

Основна ідея бібліотеки полягає в тому, щоб надати можливість легко шарити модулі між незалежними застосунками, не налаштовуючи їх залежності вручну. Вона буде хорошим вибором, якщо у вас є декілька незалежних застосунків, які потребують спільного функціоналу, але не обов’язково повинні бути жорстко інтегровані в спільний UI та одночасно відображатись на одному екрані.

Крім нашого прикладу з незалежними застосунками, з Webpack 5 Module Federation ви можете, наприклад, побудувати динамічну бібліотеку компонентів, яка буде незалежно розроблятись та імпортуватись у відповідні застосунки вже у рантаймі.

Одним з головних недоліків Webpack 5 Module Federation є відсутність вбудованих рішень для управління життєвим циклом застосунків, роутингом та комунікаціями між мікрофронтендами. Розв’язання цих проблем лягає на плечі розробника. Однак це очікувано, оскільки Module Federation позиціонує себе саме як бібліотека для шарингу модулей, а не фреймворк для побудови мікфрофронтендів.

На практиці код для сетапу Webpack 5 Module Federation виглядатиме приблизно так:

// shell webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

У webpack.config.js ми вказуємо, що shell залежить від мікрофронта app1, який буде завантежний у рантаймі з http://localhost:3001/remoteEntry.js. Файл remoteEntry.js не містить весь код мікфрофронта, у ньому знаходяться метадані про доступні експортовані модулі. Цей файл буде завантажений одразу при ініціалізації shell та вже за потреби буде підвантажувати відповідний експортований функціонал з мікфрофронта.

Далі у конфізі мікрофронту ми вказуємо, що він експортує функцію render. Цю функцію у потрібний момент підвантажить shell та виконає її, щоб відрендерити мікрофронт:

// app1 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      filename: 'remoteEntry.js',
      exposes: {
        './render': './src/renderApp',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

Також в іншому файлі мікрофронта опишемо цю функцію для рендеру:

// app1 renderApp.js
import ReactDOM from 'react-dom';

function App1() {
  return <h2>Hello, I'm App1, independent microfrontend</h2>;
}

export function renderApp({ el }) {
  ReactDOM.render(<App1 />, el);
}

Як бачимо, ми очікуємо, що shell передасть їй елемент, в який потрібно відрендерити цей мікрофронт.

Наступний код відповідає за інтеграцію цієї функції у shell:

// shell App1.js
import { useEffect, useRef } from 'react';
import { renderApp } from 'app1/render';

export function App1() {
  const renderEl = useRef();

  useEffect(() => {
    renderApp({ el: renderEl.current });
  }, []);

  return <div ref={renderEl} />;
}

Код import { renderApp } from 'app1/render' дістане експортовану функцію renderApp з файлів мікрофронту. Шлях до цієї функції ми знаємо з метаданих, які знаходяться у файлі remoteEntry.js, що був завантажений одразу під час ініціалізації shell.

Але лише після імпорту в цьому місці почнеться завантаження всього експортованого коду мікрофронта. Це дає нам можливість динамічно імпортувати код мікрофронта залежно від поточного роуту і таким чином реалізувати lazy-loading.

Так виглядає стандартний сетап Webpack Module Federation. Якщо у вас залишились запитання, рекомендую переглянути цей репозиторій на GitHub, де є безліч прикладів використання Module Federation. Тепер перейдемо до розгляду іншої бібліотеки, а насправді фреймворку, — Single-SPA.

Single-SPA

Якщо ви шукаєте щось більше, ніж Webpack 5 Module Federation, то Single-SPA може бути саме тим, що вам потрібно. На відміну від Webpack 5 Module Federation, він спеціально розроблений для побудови мікрофронтендів і має у своєму арсеналі не тільки можливість шарити модулі між застосунками, але й інструменти для управління життєвим циклом застосунків, їх роутингом та побудовою комунікації між ними.

Single-SPA акцентує на керуванні життєвим циклом мікрофронтендів. Це особливо важливо в проєктах, де одночасно на сторінці можуть бути кілька мікрофронтендів, реалізованих навіть на різних технологіях.

Серед недоліків Single-SPA виділяють складніше конфігурування порівняно з Module Federation та більший час, необхідний для вивчення концепцій та особливостей його екосистеми.

Давайте поверхнево розглянемо, як може виглядати сетап Single-SPA, не занурюючись у деталі, про які ми вже говорили при розгляді сетапу Module Federation вище:

// shell index.html
<html>
  <head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.10.3/system.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.10.3/extras/amd.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.10.3/extras/named-exports.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script src="https://unpkg.com/single-spa/lib/umd/single-spa.min.js"></script>
    <script>
      System.config({
			  map: {
			    react: "https://unpkg.com/react@17/umd/react.production.min.js",
			    "react-dom":
			      "https://unpkg.com/react-dom@17/umd/react-dom.production.min.js",
			    app1: "https://localhost:3000/dist/main.js",
			  },
			  packages: {
			    app1: {
			      main: "index.js",
			      format: "AMD",
			      defaultExtension: "js",
			    },
			  },
			});
			
			singleSpa.registerApplication(
			  "app1",
			  () => System.import("app1"),
			  () => location.pathname.startsWith("/app1")
			);
			
			singleSpa.start();
    </script>
  </body>
</html>

Тут ми спочатку підключаємо необхідні бібліотеки та описуємо конфігурацію модулів для SystemJS. Далі ми вказуємо шляхи до наших залежностей, зокрема вказуємо, що мікрофронт буде завантажуватись з https://localhost:3000/dist/main.js. Після цього ми реєструємо мікрофронт в Single-SPA та конфігуруємо, за яким URL він буде доступний.

У коді мікрофронта необхідно експортувати функції, які відповідають за його запуск, монтування та розмонтування. Нижче наведено приклад коду, який демонструє, як можуть виглядати ці функції:

import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';

function App() {
  return <h2>Hello, I'm App1, independent microfrontend</h2>;
};

function domElementGetter() {
  return document.getElementById('root');
}

async function bootstrap() {
	// ...
}

async function mount() {
  ReactDOM.render(<App />, domElementGetter());
}

async function unmount() {
  ReactDOM.unmountComponentAtNode(domElementGetter());
}

export { bootstrap, mount, unmount };

export default singleSpaReact({
  React,
  ReactDOM,
  domElementGetter,
});

Як бачимо, загальна концепція Single-SPA досить схожа на Webpack 5 Module Federation, проте ми використовуємо вбудований роутер та окремо описуємо функції життєвого циклу мікрофронту (bootstrap, mount, unmount).

Важлива ремарка

Насправді не зовсім коректно детально порівнювати Webpack 5 Module Federation та Single-SPA. Оскільки Single-SPA під капотом може використовувати Module Federation або SystemJS (ми розглядали SystemJS раніше) залежно від вашого вибору, щоб працювати з віддаленими модулями. Детальніше про це можна почитати в їх документації.

Тому важливо розуміти, що обираючи між Webpack 5 Module Federation та Single-SPA, ви не обираєте між двома різними підходами. Це скоріше вибір між розгортанням цілої екосистеми мікрофронтів (Single-SPA) або розгортанням невеликої частини цієї екосистеми, яка вирішує основні проблеми (Webpack Module Federation). Важливо пам’ятати, що кожен підхід має свої переваги та обмеження, тому вибір залежить від потреб вашого проєкту та вашої команди.

В нашому випадку, ми обрали простіший підхід — Module Federation. Таким чином, подальші приклади та розгляд мікрофронтендів будуть зосереджені на контексті Webpack 5 Module Federation.

Повертаючись до початку розділу, нагадаємо, що ми перейшли від варіанту інтеграції застосунків під час білду до ідеї робити це в рантаймі за допомогою мікрофронтендів. Та освіжимо в памʼяті схему взаємозвʼязків наших застосунків:

Тепер детальніше розгляньмо переваги та недоліки цього підходу.

Переваги такого підходу

Ми успішно забезпечили незалежність наших застосунків та повністю задовольнили всі вимоги, що були поставлені в попередніх розділах.

Особливу увагу хочу звернути на вимогу про можливість створення нових застосунків не тільки на React. Зверніть увагу, що у попередніх прикладах коду з Module Federation мікрофронтенд викликає ReactDOM.render. На перший погляд, це може здатися дивним, оскільки в shell обов’язково має бути свій виклик ReactDOM.render. Але саме такий підхід дозволяє зробити мікрофронтенди максимально незалежними застосунками, які взаємодіють тільки за допомогою параметрів та івентів, як ми вже бачили на одній з перших схем в цьому розділі. Це дає можливість використати будь-яку технологію для реалізації мікфрофронтенду.

Тепер в мікрофронті замість ReactDOM.render ми можемо використовувати createApp із Vue.js, bootstrapModule з Angular, new App зі Svelte і т.д. В кінцевому результаті, мікрофронт просто повинен експортувати функцію для свого рендеру.

Недоліки такого підходу

Проте наше прагнення до максимальної незалежності застосунків призвело до виникнення додаткових проблем:

1. Ми змушені дублювати всі контексти, що потрібні для shell та вкладених мікрофронтів. Крім того, що дублювання коду само собою є поганою практикою, це також ускладнює можливість використання спільного стейту між shell та мікрофронтами. Навіть якщо вони написані на React, оскільки вони спілкуються через функцію для рендеру.

Детальніше це можна побачити на прикладі коду нижче:

// shell index.js
import ReactDOM from 'react-dom';
import { App1 } from './App1';
import { ThemeProvider } from './ThemeProvider';
import { UserProvider } from './UserProvider';

ReactDOM.render(
  <UserProvider>
    <ThemeProvider>
      <App1 />
    </ThemeProvider>
  </UserProvider>,
  document.getElementById('root'),
);


// shell App1.js
import { useContext, useEffect, useRef } from 'react';
import { renderApp } from 'app1/render';
import { ThemeProvider } from './ThemeProvider';
import { UserProvider } from './UserProvider';

export function App1() {
  const renderEl = useRef();
  const user = useContext(UserProvider);
  const theme = useContext(ThemeProvider);

  useEffect(() => {
    renderApp({ el: renderEl.current, user, theme });
  }, [user, theme]);

  return <div ref={renderEl} />;
}

Shell змушений діставати дані з контекстів та передавати їх параметрами мікфрофронту, щоб той мав доступ до них. Звісно, в такому підході ці дані не будуть динамічно оновлюватись в app1 при зміні їх в shell.

Тому ми змушені додати їх в залежність useEffect, щоб перерендерити весь мікфрофронт при їх зміні.

// app1 renderApp.js
import ReactDOM from 'react-dom';
import { ThemeProvider } from './ThemeProvider';
import { UserProvider } from './UserProvider';

function App1() {
  return <h2>Hello, I'm App1, independent microfrontend</h2>;
}

function renderApp({ el, user, theme }) {
  ReactDOM.render(
    <UserProvider value={user}>
      <ThemeProvider value={theme}>
        <App1 />
      </ThemeProvider>
    </UserProvider>,
    el,
  );
}

export { renderApp };

Отримавши ці дані від shell, мікрофронт може захотіти зробити їх глобально доступними у своєму контексті. В цьому випадку в app1 доведеться дублювати контексти.

2. Потрібно створювати додаткові роутери: один, щоб контролювати глобальну навігацію на рівні shell та ще по одному в кожному мікрофронті, щоб контролювати їх локальні навігації. Детальніше це показано на схемі нижче.

Також розглянемо, як виглядатиме наш попередній код, якщо в нього додати налаштування роутерів. Спочатку налаштуємо глобальний роутер в shell:

// shell index.js
import ReactDOM from 'react-dom'
import Router from './Router'

ReactDOM.render(
	<UserProvider>
	  <ThemeProvider>
	    <Router />
	  </ThemeProvider>
	</UserProvider>,
	document.getElementById('root'),
);


// shell Router.js
import { BrowserRouter, useRoutes } from 'react-router-dom';
import MainPage from './MainPage';
const App1 = lazy(() => import("./App1"));

export function Router() {
  const routes = useRoutes([
    {
      path: 'main',
      element: <MainPage />,
    },
    {
      path: 'app1/*',
      element: <App1 />,
    },
  ]);

  return <BrowserRouter>{routes}</BrowserRouter>;
}

Тепер починається найцікавіше: реалізовуємо передавання стану глобального роутера до мікрофронтового роутера і підписуємось на зміну мікрофронтового, щоб оновлювати стан глобального роутера:

// shell App1.js
import { useContext, useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { renderApp } from 'app1/render';
import { ThemeProvider } from './ThemeProvider';
import { UserProvider } from './UserProvider';

export function App1() {
  const renderEl = useRef();
  const user = useContext(UserProvider);
  const theme = useContext(ThemeProvider);
	const location = useLocation();
  const navigate = useNavigate();
  const onShellNavigateRef = useRef();

  useEffect(() => {
    const { onShellNavigate } = renderApp({
      el: renderEl.current,
      user,
      theme,
      initialLocation: location,
      onNestedAppNavigate: ({ location: newLocation }) => {
        navigate(newLocation);
      },
    });
    onShellNavigateRef.current = onShellNavigate;
  }, []);

  useEffect(() => {
    onShellNavigateRef.current?.(location);
  }, [location]);

  return <div ref={renderEl} />;
}

Якщо ви досі не заплутались, то зараз ми це виправимо. Подивімось, як вкладений роутер підписується на оновлення глобального та сам сповіщає його про свої оновлення:

// app1 renderApp.js
import ReactDOM from 'react-dom';
import { createMemoryHistory } from 'history';
import Router from './Router';
import { ThemeProvider } from './ThemeProvider';
import { UserProvider } from './UserProvider';

export function renderApp({ el, user, theme, initialLocation, onNestedAppNavigate }) {
  const history = createMemoryHistory({
    initialEntries: [initialLocation],
  });

  history.listen(onNestedAppNavigate);

  ReactDOM.render(
    <UserProvider value={user}>
      <ThemeProvider value={theme}>
        <Router history={history} />
      </ThemeProvider>
    </UserProvider>,
    el,
  );

  return {
    onParentNavigate: (location) => {
      history.push(location);
    },
  };
}


// app1 Router.js
import { unstable_HistoryRouter as HistoryRouter, useRoutes } from 'react-router-dom';
import UserPage from './UserPage';
import UsersPage from './UsersPage';

export function Router({ history }) {
  const routes = useRoutes([
    {
      path: 'app1',
      children: [
        {
          path: 'users',
          element: <UsersPage />,
        },
        {
          path: 'user/:id',
          element: <UserPage />,
        },
      ],
    },
  ]);

  return <HistoryRouter history={history}>{routes}</HistoryRouter>;
}

Підсумовуючи, в цьому прикладі ми використовуємо два роутери. Перший — стандартний BrowserRouter в shell, який відповідає за глобальну навігацію і рендеринг глобальних сторінок та мікрофронтендів.

Другий — HistoryRouter в кожному з мікрофронтендів, який отримує від shell дані про поточну історію та контролює роутинг на рівні мікрофронтенду.

Між роутерами встановлюється двосторонній звʼязок: при зміні глобального роутера, ми сповіщаємо відповідний локальний, щоб він оновив свій стан, а при зміні будь-якого локального роутера ми сповіщаємо глобальний, щоб він змінив свій стан.

На перший погляд, це може здаватись дуже дивним, однак такий підхід рекомендується, наприклад, в офіційних прикладах Module Federation.

3. Якщо ви використовуєте TypeScript, вам доведеться дублювати описи сигнатур функцій рендеру мікрофронтів в shell. Адже вони знаходяться в різних репозиторіях та підвантажуються у рантаймі, відповідно TypeScript при імпорті не зможе підвантажити потрібні типи.

Це можна обійти, винісши їх до нашої shared бібліотеки, або використавши бібліотеку для webpack module federation. Однак у випадку з shared-бібліотекою доведеться робити додатковий пул реквест при зміні цих сигнатур. А конфігурація бібліотеки для webpack module federation може не завжди пройти гладко.

4. Оскільки ми залишили нашу shared-бібліотеку у вигляді npm-пакета, кожного разу, коли потрібно додати новий функціонал до неї, необхідно робити пул-реквест з оновленням її версії в усіх мікрофронтендах, що займе додатковий час.

5. Також для глобального функціоналу, який охоплює більше ніж один мікрофронт, необхідно створювати окремі пул-реквести, оскільки застосунки розміщені у різних репозиторіях. Цей недолік стає особливо актуальним на старті розробки, коли подібних змін буде багато.

Вдосконалення підходу з мікрофронтендами: зміна способу комунікації та об’єднання застосунків у один репозиторій

Звісно, ми врахували можливість створення мікрофронтендів на будь-яких технологіях, але чи потрібно для цього робити їх настільки незалежними? Розбираймося.

Загалом, проблеми попереднього підходу можна розділити на дві групи:

  1. Shell компонує мікрофронтенди як максимально незалежні застосунки, що дає можливість використовувати будь-який фреймворк у мікрофронтендах. Однак, такий підхід призводить до відсутності спільного стейту між ними та дублювання контекстів.
  2. Оскільки всі проєкти знаходяться в різних репозиторіях, ми змушені робити більше пул-реквестів і витрачати час на додаткові налаштування TypeScript для них.

Поговорімо детальніше про те, що ми можемо зробити з цим, і почнемо з першої групи проблем.

Змінюємо спосіб комунікації між shell та мікрофронтендами

Якщо розглядати типові приклади конфігурації Module Federation, коли всі мікрофронтенди використовують React, то ми побачимо, що зазвичай мікрофронтенд експортує не функцію для свого рендеру, а окремі компоненти або кореневий компонент, який рендерить весь застосунок:

// shell index.js
import ReactDOM from 'react-dom';
import { App1 } from 'app1/App';
import { ThemeProvider, UserProvider } from 'shared-lib';

ReactDOM.render(
  <UserProvider>
    <ThemeProvider>
      <App1 />
    </ThemeProvider>
  </UserProvider>,
  document.getElementById('root'),
);


// app1 App1.js
import { useContext } from 'react';
import { ThemeContext, UserContext } from 'shared-lib';

export function App1() {
  const user = useContext(UserContext);
  const theme = useContext(ThemeContext);

  return <h2>Hello, I'm App1, independent microfrontend</h2>;
}

Оскільки shell рендерить мікрофронт безпосередньо як компонент та вони обоє використовують спільний контекст з shared-бібліотеки, тепер shell та app1 мають спільний стейт та доступ до актуальних даних в ньому. Внаслідок цього також зникає проміжний шар у вигляді файлу конфігурації App1.js в shell, адже тепер немає потреби додатково обробляти колбек для рендерингу.

Далі постає питання про необхідність у ще одного роутера на рівні мікфрофронта. Дійсно, тепер, коли ми маємо спільний контекст між застосунками, ми можемо відмовитись від нього. Тепер нехай мікрофронт експортує об’єкт з конфігурацією глобального роутера, замість того, щоб реалізовувати ще один роутер всередині себе.

Розглянемо, як зміниться наш попередній сетап з роутерами:

// app1 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      filename: 'remoteEntry.js',
      exposes: {
        './routerConfig': './src/useRoutes',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};


// shell Router.js
import { BrowserRouter, useRoutes } from 'react-router-dom';
import MainPage from './MainPage';

const App1RouterConfig = lazy(() => import("app1/routerConfig"));
const { useRoutes: useApp1Routes } = App1RouterConfig;

export function Router() {
  const app1Routes = useApp1Routes();
  const routes = useRoutes([
    {
      path: 'main',
      element: <MainPage />,
    },
    ...app1Routes,
  ]);

  return <BrowserRouter>{routes}</BrowserRouter>;
}

// app1 routerConfig.js
import { useRoutes as useBaseRoutes } from 'react-router-dom';
import UserPage from './UserPage';
import UsersPage from './UsersPage';

export function useRoutes() {
  return useBaseRoutes([
    {
      path: 'app1',
      children: [
        {
          path: 'users',
          element: <UsersPage />,
        },
        {
          path: 'user/:id',
          element: <UserPage />,
        },
      ],
    },
  ]);
}

Бачимо, що тепер код виглядає набагато простіше. Проте чи означають ці зміни те, що ми можемо підключати мікрофронтенди тільки на React? Насправді ні. Для інших технологій ми можемо застосовувати попередній підхід до комунікації та роутингу. Таким чином, ми можемо комбінувати обидва підходи та використовувати найкраще з них обох.

Проте, варто мати на увазі, що цей підхід буде оптимальним, якщо ви плануєте використовувати React як у shell, так і у більшості мікрофронтів. У нашому випадку це було доречно, але якщо ви не маєте «основної» технології, то краще дотримуватись попередньої ідеї з комунікацією через колбек для рендерингу.

То ж ми успішно вирішили першу групу проблем. Тепер перейдемо до другої.

Переносимо всі застосунки в один репозиторій

Щоб зменшити проблеми, пов’язані з розподілом застосунків по різних репозиторіях, ми вирішили об’єднати їх у єдиному монорепозиторії. Монорепозиторій — це підхід, який дозволяє зберігати код декількох проєктів в одному репозиторії. Якщо ви ще не знайомі з цим підходом, рекомендуємо дізнатися більше на сайті, де описані загальні принципи монорепозиторію та інструменти для його налаштування.

Після винесення застосунків у один репозиторій, ми все ще можемо проводити незалежні деплої та мати незалежні цикли розробки для кожного з них. Адже, хоча їх код і знаходиться в одному репозиторії, кожен з них має свій незалежний пайплайн.

Таким чином, наша схема майже не змінилась з попереднього розділу. Єдина відмінність полягає в тому, що тепер shared-бібліотека інтегрується в потрібні мікрофронти на етапі їх білда, замість того, щоб деплоїтися як окремий npm-пакет:

Організація наших застосунків в одному репозиторії дозволила нам отримати нові переваги. Тепер ми можемо легко шарити shared-бібліотеку, простіше управляти версіями залежностей, можемо робити один пул-реквест на глобальну фічу, однією командою піднімати весь наш оркестр мікрофронтів та значно простіше інтегрувати TypeScript.

Крім того, використання інструментів, таких як NX, дає нам можливість запускати білди та тести лише для тих частин коду, на які вплинули наші зміни. NX дозволяє нам побудувати межі та взаємозвʼязки між застосунками та бібліотеками навіть в рамках однієї кодової бази.

Додатково, за допомогою плагіну NX налаштування Webpack Module Federation стає простішим, а бойлерплейти залишаються прихованими від нас.

Ви можете справедливо зауважити, що раніше ми мали можливість обмежувати доступ команди лише до власного репозиторію, тоді як зараз кожна команда може вносити зміни в код будь-якого застосунку.

Однак, в монорепозиторії цю проблему можна вирішити, обмеживши користувачів на рівні певної директорії, або додавши обов’язковий апрув від «власників коду» для кожного пул-реквесту. Це можна налаштувати в файлі CODEOWNERS, який має знаходитись в корені вашого репозиторію.

Тепер, коли ми обговорили способи вирішення обох проблем з попереднього розділу, розгляньмо переваги та недоліки нашого кінцевого рішення.

Переваги такого підходу

Ми й надалі задовольняємо усі наші вимоги з початку статті. Однак, на відміну від попереднього рішення, ми тепер можемо без проблем шерити глобальний стейт між шелом та мікрофронтами. Це дозволяє нам уникнути дублювання коду та спрощує обмін глобальними даними.

А після переходу до монорепозиторію ми успішно вирішили проблему налаштування TypeScript та отримали можливість легко шерити спільний функціонал (нашу shared бібліотеку) та інструментарій (конфігами для ESLint, TypeScript, Jest, Github Actions та інших).

Крім того, тепер ми можемо вносити глобальні зміни одним пул-реквестом, що спрощує проведення глобального деплою або реверту.

Недоліки такого підходу

Але звичайно, кожне рішення має свої недоліки. Новий спосіб комунікації між мікрофронтами та shell дійсно дозволяє досягти більшої ефективності, але на жаль, призводить до збільшення їх взаємозалежності та зв’язування (coupling). Це може мати неочікувані наслідки при внесенні змін в дані, що передаються shell до мікрофронтів.

Крім того, такий спосіб комунікації зменшує явність контракту взаємодії між мікрофронтами та шелом, порівняно з використанням підходу з колбеком для рендеру.

Своєю чергою, перехід до монорепозиторію дійсно вирішив багато проблем, проте він також створив нові виклики для команди. Нові інструменти та процеси потребують часу для навчання та освоєння, що може уповільнити процес розробки та підвищити витрати на навчання нових співробітників.

Крім того, необхідно встановити додаткові правила для координації роботи команд в межах монорепозиторію, а також домовитись з іншими командами про використання загального репозиторію.

Тому важливо враховувати ці недоліки і обирати той підхід, який найкраще підійде вашим потребам і вимогам.

Корисні поради щодо роботи з мікрофронтами та речі, на які варто звернути увагу

  • Майте вагомі причини для використання мікрофронтендів. Беріть їх якщо впевнені, що вони вирішать ваші проблеми та дадуть можливість швидшої розробки та підтримки коду. В нашому випадку можливість незалежних деплоїв була основною бізнес-вимогою, тому ми обрали мікрофронтенди.
  • Виділіть достатньо часу на ретельний аналіз усіх вимог проєкту, щоб знайти оптимальний підхід до реалізації мікрофронтів. Як ми зазначали у статті, існує багато різних підходів з власними тонкощами, перевагами та недоліками. Варто ретельно подумати про способи комунікації між мікрофронтами, розглянути кількість одночасних мікрофронтендів на екрані, врахувати можливість використання різних фреймворків та вирішити, чи бажано зберігати код в одному репозиторії, чи розділити його на декілька.
  • Будьте готові, що при переході до мікрофронтендної архітектури загальна складність застосунку може збільшитися. Адже при розділенні застосунку на мікрофронтенди з’являється додаткова складність, якої не було у монолітному підході. Тепер потрібно продумати та організувати взаємодію між мікрофронтендами, керувати їх конфігурацією та обмежити їх зони впливу.
  • Краще починати проєкт в одному репозиторії, навіть якщо ви не плануєте використовувати монорепозиторій далі. На початку буде багато breaking changes, тому краще тримати весь код поруч, щоб зменшити час на їх вирішення.
  • Враховуйте ізоляцію стилів. Глобальні або неунікальні селектори можуть призвести до конфліктів стилів, особливо у великих проєктах з багатьма мікрофронтами. Тому варто вже на початку проєкту задуматись над ізоляцією стилів. Це можна зробити за допомогою підходів CSS modules, CSS-in-JS, BEM та інших, або ж використати бібліотеку готових компонентів, яка забезпечить ізольованість їх стилів.
  • Відключіть кешування для remoteEntry.js файлів. Ці файли не є великими, оскільки містять лише шляхи до зовнішніх файлів мікрофронтів. Відключення кешування не вплине на швидкість завантаження сайту, але гарантуватиме, що користувачі завжди отримуватимуть потрібний конфіг, а не закешовану версію, яка може посилатись на неіснуючий після деплою файл.
  • Враховуйте потребу у SSR для вашого проєкту. Реалізація мікрофронтендів з використанням SSR буде мати свої нюанси, які важливо врахувати. Як приклад, ви можете переглянути реалізацію SSR з Module Federation у цьому репозиторії з офіційними прикладами.
  • Для забезпечення швидкого деплою та реверту змін важливо мати належну автоматизацію CI/CD. Оскільки мікрофронтенди розробляються та деплояться незалежно один від одного, кожен з них повинен мати свою власну конфігурацію CI/CD, що дозволить уникнути залежності між ними.
  • Використовуйте lazy-loading мікрофронтендів в залежності від поточного відображення. Підвантажуйте всі remoteEntry.js одразу при завантаженні сторінки, але ініціюйте завантаження тільки необхідного коду для поточної сторінки. Також, слід звернути увагу на код, який завантажується в рантаймі продакшену, оскільки з мікрофронтендами можуть виникати проблеми з дублюванням залежностей.

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

Діліться думками та запитаннями у коментарях, щоб ми могли разом їх обговорити.

Дякую за увагу!

👍ПодобаєтьсяСподобалось21
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за дуже інформативну статтю. Особливо цінно, що описана конкретна проблема та декілька етапів її вирішення. Цікаво, чи не розглядали ви окремий shared module для state management? Я думала про використання Redux у випадку різних фрейморків як описано тут. І ще питання з приводу використання atomic based state management libraries, наприклад, Jotai, як радять для module federation із завтосуванням виключно React. Чи не вивчали це?

Конкретно в нашому випадку глобального стейту було мало і ми його шарили між shell та мікфрофронтами через контексти. В більшості наш стейт — це був кеш даних, отриманих з серверу і для його управління ми взяли React Query.
Тому глибоко у state management libraries не копали, але я з цікавістю почитав би про ваш досвід роботи з мікрофронтами зі складними стейтами)

Діліться думками

Судячи з вимог на початку статті — це абсолютно різні проекти, просто умовно з однаковими кнопками\інпутами.

Варіант, розкидати адмінки на різні поддомени admin1.your-domain.ua, admin2.your-domain.ua, etc.
Кілька разів «скопіпастить» сайдбар — невелика біда (роботи джуну на день) в порівнянні з надмірною складнісю кодової бази, бас-фактору.
З плюсів — дійсно незалежність у всьому, і справжнє перезавантаження сторінки між адмінками — підчищає пам’ять\баги, і зразу зрозуміло яка команда відповідальна і за що.

Цілком робочий варіант, як на мене.
Особливо якщо не лякає дублікату коду, гірший UX при переходах між сторінкам та обмеження при шерингу спільних даних між сторінками через LS або query params.
Але не знаю чи дійсно буде простіше наконфігурувати кастомний білд під це, ніж взяти мікрофронти де це зроблено «з коробки» і отримати кращий UX, DX та більшу гнучкість (якщо раптом в майбутньому потрібно буде одночасно показати декілька додатків на екрані).

гірший UX при переходах між сторінкам

Суперечлеве твердження. Яка різниця де дивитись на спіннер — в ui чи во вкладці браузеру. Майже всюди де є справжнє перезавантаження між сторінками — сам додаток по ітогу відчувається шустріше, ніж коли крутиться один товстий-жирний single-spa. Наприклад gitlab — не бачу там поганого ux, при цьому перезавантаження мало не на кожному роуті.

не знаю чи дійсно буде простіше наконфігурувати кастомний білд під це

Я орієнтувався на вимоги напочатку статті, і я б, як керівник, закомітився б на тому — що все максимально незалежне. Різні репи, різні команди розробки, різні цикли деплою. Навіть в перший рік-два заборонив би виносити «кнопки» та щось спільне в окремі npm-пакети. Вже потім, через деякий час, виходячи з історії розвитку проекту, в рамках окремої задачі можно шось виносити — коли буде все зрозуміло. if-ов буде набагато менше. А поки на початку ми не знаємо що буде «загальне», а що буде «часткове».

Якщо раптово, раз на рік, прийде новий «дизайнер» (замість старого) і скаже треба квадратні кноки замінити на круглі — просто пишемо в чат лідам команд — хлопці, треба кнопки поправити, вони вже делегують це своїм джунам. Це не боляче, це просто та прогнозовано, і нема ніякого high coupling. «копіпастить» ui — невелике зло.

Йшов тим же шляхом завжди. Коли треба нова адмінка — копі паст репо існуючої адмінки, з видаленням зайвого доменного коду- займає годину.

Якщо курці відрубати голову, то вона ще 15 хвилин буде розповідати про мікрофронтенди. Камон, 5+ років вже жується ця жуйка.

ІМХО: мікрофронтенди — класне вирішення проблеми, якої не мало б взагалі існувати

Мені здається, що корінь проблеми в непередбачуваності проекту на початку та зростанні його складності з часом. Тому вона точно буде існувати)

Як на мене, проблема купи кривого/старого коду на фронті залишилась десь у старому WordPress і у всякому legacy (з яким я теж щоденно працюю). Якщо ви використовуєте модулі та один вхідний js файл, то взагалі не розумію в чому проблема (наприклад) на одній сторінці якийсь мега складний календар з реєстрацією івентів на React, на іншій (чи на тій же) якийсь конструктор на Vue? І ці штуки можуть без проблем робити різні команди в одному repo, і весь код в одному бандлі на проді.

Так, але тоді у нас буде спільний процес деплою для обох фіч. Виходить, що навіть при мінорній зміні в календарику потрібно перезбирати весь проект і деплоїти його повністю. Розумію, що хеші зміняться тільки у файлів календарику і всі інші залишаться старими, тому фактично редеплой пройде тільки для потрібного функціоналу. Але тімці, що робить календарик треба знати про деплойний цикл інших команд і підлаштовуватись під нього. А з мікрофронтами у команди календарика буде свій незалежний деплоймент процес, який вони можуть проводити коли їм заманеться.

Працював на проектах різної величини і з особистого досвіду бачу що для великих проектів розділення моноліту на частини було правильним рішенням. Для нас це дозволило бути більш швидкими, незалежними в розробці, деплої і не «наступати на ноги» іншим розробникам. Звісно як «бонус» є різні складнощі в роутингу, спільних даних, тощо, але переваг значно більше.

Так я ж і написав — класне вирішення.
Мій досвід мені показує, що якщо не сильно тупили з самого початку життя проекту, то навіть при потребі використовувати різні фреймворки на одній сторінці, це можна зробити й без мікрофронтендів.

Код пишуть люди тому без шансів що все написано ідеально, такого просто ніде не має і ніколи не буде. Є бізнес якому потрібні рішення а не безкінечний рефакторинг, та й справа не завжди в якості коду. Технології/вимоги/люди змінюються і архітектура підлаштовується під потреби і вимоги. В статті правильно пише, що потрібно добре подумати і все зважити перед тим щоб впроваджувати цей підхід.

Підписатись на коментарі