Всем привет! Меня зовут Иван Соснович, я сеньор фронтенд-разработчик в СберТехе, тружусь в команде Platform V Kintsugi — это графический инструмент для сопровождения, мониторинга и диагностики Postgres-like СУБД. По итогам прошлой статьи мнения читателей разделились.

В эпоху ИИ о структуре кода задумываются все реже, однако архитектурные решения остаются критически важными для развития и долгосрочной поддержки систем. Надеюсь, что эта статья будет полезна и появятся советы, как упростить и облегчить систему сборки.

Сразу скажу, что код проекта будет писать ИИ через GigaCode CLI, скиллы останутся в репозитории.

Предусловия: почему мы этим занимаемся

В нашу команду поступил запрос: сможем ли мы отдавать часть своих фичей для встраивания в другие сервисы, чтобы они были независимыми, могли работать и поддерживать свою функциональность. По части бэкенда и DevOps — отдельные нюансы. Но основной вопрос возник: как из большого монолита фронтенд-приложения выделить часть логики и отдать только её, без лишнего кода. И мы пошли думать.

Разбираем идеи

Идея первая: настройка сборщика (спойлер: рассматривалась, но была отвергнута)

Суть идеи заключалась в реализации настройки сборщика так, чтобы он собирал только тот кусок кода, который необходим, и настроить нужные точки входа.

Однако сразу столкнулись с минусами этого решения:

Достоинства

Недостатки

Простота начальной настройки.

Жёсткая привязка к сборщику.

Сложность масштабирования.

Проблемы с изоляцией зависимостей.

Идея вторая: клонирование логики

Клонирование логики в отдельное приложение сразу ушло в «топку» — поддержка двух сервисов (тестирование и разработка) очень накладна.

Идея третья: микрофронтовое приложение (она и была выбрана для реализации)

Я подумал: «А почему не сделать микрофронтовое приложение, которое будет встраиваться и в наш хост, и передаваться в другие хосты с соблюдением необходимых контрактов?». Не увидел причин не попробовать.

Приступим к реализации

На первый взгляд это казалось накладным и трудозатратным, но отступать было поздно.

Стартовый репозиторий и коммит.

Подготовка репозитория

Неважно, монолит фронта у вас или набор сервисов как у нас — структура меняется только в директории фронта (без названия папки вашего фронта).

Ознакомиться с коммитом.

Почему важно сразу подготовить репозиторий

Правильная структура для микросервисной архитектуры:

  • не будет кросс-зависимостей в модулях;

  • изоляция модулей и пакетов;

  • не будет конфликтов при работе в большой команде и при параллельной разработке.

Планирование микрофронтов

Микрофронтовые приложения будут реализованы как ES-модули. Ниже описаны краткие преимущества:

  1. Нативная изоляция и отсутствие конфликтов глобальных переменных: каждый микрофронтенд, собранный как ESM, работает в собственном пространстве имен. В отличие от CommonJS или глобальных скриптов (IIFE), модули не засоряют window и не конфликтуют версиями библиотек. Это решает проблему «войны зависимостей» между командами.

  2. Мгновенная сборка и загрузка (Fast Refresh и Instant Loading): ES-модули — это нативная фича браузера. Вы можете подавать их без бандлера (или с минимальной транспиляцией) в dev-режиме. Это дает мгновенный HMR (Hot Module Replacement) — изменение в любом микрофронтенде сразу отображается без пересборки всего приложения.

  3. Древовидная тряска (Tree Shaking) на уровне фреймворка: современные сборщики (Vite, Webpack 5) отлично анализируют ESM. Если ваша общая библиотека (например, моментные функции) не используется в конкретном микрофронтенде, она не попадет в его бандл. Для import это работает гораздо точнее, чем для require.

  4. Упрощенная версионность и кэширование (HTTP Cache): можно экспортировать свой микрофронтенд как ESM-файл app.mjs. Браузеры кэшируют такие модули по стандарту HTTP. При изменении кода меняете только URL или версию в importmap — пользователь догружает только измененную часть, а не весь монолит.

  5. Естественная поддержка динамических импортов (Dynamic Import): микрофронтенды, собранные как ESM, позволяют использовать import() для «ленивой» загрузки по требованию. Это дает нативный control-flow загрузки без велосипедов с <script> тегами и событиями onload.

  6. Единый «контракт» для разных фреймворков: ESM — это универсальный формат. Если один микрофронтенд написан на Svelte, другой на React, а третий на Vue — все они могут «договориться» через ESM-экспорты, передавая друг другу данные или shared dependencies.

  7. Меньше накладных расходов на интеграцию (No Shell Overhead): в системах вроде Module Federation или iframe вам нужен «контейнер-мост». С ESM подключается модуль в host-приложение через <script type="module"> или через import map— никакой лишней очереди сообщений (postMessage) или прокси-библиотек.

Для полной функциональности нам требуется контракт общения между хост-сервисом и микрофронтовым приложением. Чтобы в каждом сервисе и в хосте не клонировать одни и те же библиотеки и общие сущности, нужна ещё одна общая библиотека для использования и в хосте, и в сервисах.

План действий

Сначала реализуем общую библиотеку компонентов и утилит, чтобы сразу перенести на неё наш хост, протестировать и после проверки двигаться дальше. По формированию библиотеки — решать вам: сделать несколько библиотек под нужды или создать одну общую с компонентами, утилитами, провайдерами и так далее. Мы сделали одну общую для более точной поддержки и версионирования.

Посмотрите на коммит с реализацией библиотеки.

Общую библиотеку ставим из tgz-файла. Зачем? Чтобы версионировать её и более мягко обновляться в сервисах и хосте (при правильном и возможном варианте, библиотеку можно вынести в артифактори, например npmrs/nexus, но у нас есть свои нюансы и выход – это хранить пакеты локально).

Далее формируем контракт общения, модуль загрузки микрофронта и всё, что необходимо для этого.

Коммит.

В зависимости от требований к проекту, наш контракт выглядит так (в коде контракт будет пустым, как абстракция):

Контракт микрофронта

В таблице ниже описан минимальный интерфейс для взаимодействия хоста и микрофронта.

Параметр

Описание

projectTheme

Текущая тема проекта

projectLocalization

Текущий язык проекта

handleAddThemeListener

Функция добавления подписчика на изменение темы хост‑приложения

handleRemoveThemeListener

Функция удаления подписчика на изменение темы хост‑приложения

handleAddLocalizationListener

Функция добавления подписчика на изменение языка хост‑приложения 

handleRemoveLocalizationListener

Функция удаления подписчика на изменение языка хост‑приложения

Сборка и загрузка микрофронта

Теперь подробнее рассмотрим вопросы: как правильно собрать и как правильно загрузить микрофронт.

Сначала реализуем (без удаления в хост-приложении) перенос микрофронта в папку apps. Проверим, что он работает как самостоятельное приложение. Далее настроим сборку приложения как микрофронтового ES-модуля.

Детальный процесс показан на диаграмме.

 Конфигурация Vite для микрофронта (vite.config.mfe.mts)

Код:

/* eslint-disable no-magic-numbers */
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { checker } from 'vite-plugin-checker';
import EnvironmentPlugin from 'vite-plugin-environment';
import * as path from 'path';

/**
 * MFE-сборка self-contained: Все зависимости (включая React/ReactDOM/emotion)
 * собираются внутрь одного файла entry-[hash].js.
 */
export default defineConfig({
  plugins: [
    react({ jsxImportSource: '@emotion/react' }),
    EnvironmentPlugin('all'),
    checker({ typescript: true, terminal: true }),
  ],
  envPrefix: 'KINTSUGI',
  base: '/',
  optimizeDeps: {
    include: ['react', 'react-dom', '@kintsugi/uikit', '@emotion/react', '@emotion/styled'],
    exclude: [],
    force: true,
  },
  build: {
    target: 'esnext',
    outDir: path.resolve(__dirname, 'build-mfe'),
    assetsDir: '',
    sourcemap: process.env.NODE_ENV === 'production' ? false : 'inline',
    manifest: true,
    cssCodeSplit: true,
    commonjsOptions: {
      include: [/node_modules/],
      sourceMap: false,
      transformMixedEsModules: true,
    },
    minify: 'terser',
    terserOptions: {
      compress: { drop_console: true },
    },
    rolldownOptions: {
      external: [],
      preserveEntrySignatures: 'strict',
      input: './src/app/mfe-entry.tsx',
      output: {
        format: 'es',
        inlineDynamicImports: true,
        entryFileNames: 'entry-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
      },
    },
  },
  resolve: {
    alias: {
      app: path.resolve(__dirname, 'src/app/'),
      pages: path.resolve(__dirname, 'src/pages/'),
      widgets: path.resolve(__dirname, 'src/widgets/'),
      features: path.resolve(__dirname, 'src/features/'),
      entities: path.resolve(__dirname, 'src/entities/'),
      shared: path.resolve(__dirname, 'src/shared/'),
    },
  },
});

Точка сборки микрофронта (mfe-entry.tsx)

Код:

import createCache from '@emotion/cache';
 import { CacheProvider } from '@emotion/react';
 import { createRoot, type Root } from 'react-dom/client';
 import { App } from './app';
 import type { MFEModule, MFEMeta } from '@fsd/mfe-shell';
 
 export const meta: MFEMeta = {
   name: 'copywala',
   version: '1.0.0',
   apiVersion: 1,
 };
 
 const roots = new WeakMap<HTMLElement | ShadowRoot, Root>();
 
 export const mount: MFEModule['mount'] = (root, ctx) => {
   let reactRoot = roots.get(root);
   if (!reactRoot) {
     reactRoot = createRoot(root as Element);
     roots.set(root, reactRoot);
   }
   const cache = createCache({
     key: 'cw',
     container: root as unknown as Node,
     prepend: true,
   });
   reactRoot.render(
     <CacheProvider value={cache}>
       <App />
     </CacheProvider>,
   );
   const unmount = () => {
     const r = roots.get(root);
     if (!r) return;
     r.unmount();
     roots.delete(root);
   };
   ctx.signal.addEventListener('abort', unmount, { once: true });
   return unmount;
 };
 
 export default App;

Скрипт сборки микросервисов

Код:

import { execSync } from "child_process";
 import { cpSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync } from "fs";
 import * as path from "path";
 
 const SERVICES = ["logDashboard"];
 const HOST_PUBLIC = path.resolve("..", "host/public");
 const MICRO_DIR = path.resolve("..", "microservices");
 const MANIFEST_INPUT_PATH = path.resolve(HOST_PUBLIC, "manifest.json");
 
 if (existsSync(MICRO_DIR)) rmSync(MICRO_DIR, { recursive: true });
 let manifest = { version: "1.0.0", services: [] };
 
 if (existsSync(MANIFEST_INPUT_PATH)) {
   try {
     const rawData = readFileSync(MANIFEST_INPUT_PATH);
     const parsedData = JSON.parse(rawData);
     manifest = { ...parsedData, services: [] };
   } catch (error) {
     console.warn("Ошибка парсинга manifest.json. Используется шаблон по умолчанию:", error.message);
   }
 }
 
 for (const name of SERVICES) {
   try {
     execSync(cd ${name} && rm -rf node_modules && npm i --legacy-peer-deps && npm run build:mfe, { stdio: "inherit" });
     const srcBuildDir = path.resolve(${name}/build-mfe);
     const dest = path.resolve(MICRO_DIR);
     mkdirSync(dest, { recursive: true });
     const esFiles = readdirSync(srcBuildDir).filter((f) => f.endsWith(".js"));
     for (const fileName of esFiles) {
       const srcFile = path.resolve(srcBuildDir, fileName);
       const destFile = path.resolve(${dest}/${name}/, fileName);
       if (statSync(srcFile).isFile()) {
         cpSync(srcFile, destFile);
       }
       manifest.services.push({ name, entry: /microservices/${name}/${fileName}, status: "active" });
     }
   } catch (e) {
     console.error(Ошибка сборки ${name}:, e.message);
     process.exit(1);
   }
 }
 
 writeFileSync(path.resolve(HOST_PUBLIC, "manifest.json"), JSON.stringify(manifest, null, 2));

Скрипт выполняет следующее: запускает сборку сервисов из конфига SERVICES (временная константа, пока нет файла конфигурации от пайплайна). Удаляет информацию о ранее смонтированных сервисах в хостовом манифесте, собирает микросервис и при успехе копирует chunk в папку микросервисов, прописывая путь до него в манифесте.

Плагин копирования микросервисов (microservicesCopyPlugin)

Код:

function microservicesCopyPlugin(): Plugin {
   const microservicesSrc = path.resolve(__dirname, '../apps/microservices');
   let buildOutDir: string;
   return {
     name: 'microservices-copy-plugin',
     apply: 'build',
     configResolved(config) {
       buildOutDir = config.build.outDir;
     },
     closeBundle() {
       if (!existsSync(microservicesSrc)) return;
       const microservicesDest = path.resolve(buildOutDir, 'microservices');
       mkdirSync(microservicesDest, { recursive: true });
       const serviceDirs = readdirSync(microservicesSrc);
       for (const dirName of serviceDirs) {
         const fileNames = readdirSync(${microservicesSrc}/${dirName});
         for (const fileName of fileNames) {
           if (fileName.endsWith('.js')) {
             const srcFile = path.resolve(${microservicesSrc}/${dirName}, fileName);
             const destFile = path.resolve(${microservicesDest}/${dirName}, fileName);
             if (statSync(srcFile).isFile()) {
               cpSync(srcFile, destFile);
             }
           }
         }
       }
     },
   };
 }

Готовый коммит.

Интеграция микрофронта в хост

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

Реализован компонент, который загружает все микрофронтовые приложения. Он загружает manifest.json, смотрит где находится микрофронт, и загружает его. В текущем DOM-узле формируется Shadow DOM и передаётся микрофронту как точка монтирования вместе со всеми нужными свойствами.

Коммит.

Дополнительные возможности при загрузке

  • Состояние загрузки и ошибки в микрофронтовых приложениях.

  • Состояния ошибок роутинга и монтирования приложения.

  • При загрузке микрофронтов в режиме разработки требуется изменить путь до папки микрофронтов на локальную папку dist или build.

Преимущества монорепозитория с микрофронтами

Категория

Описание

Простота оркестрации

Все сервисы в одном репозитории — единый CI/CD, общий код, легко развёртывать через turborepo/nx.

Общий tooling

Один набор линтеров, тестов, tsconfig, eslint, prettier на всех сервисах.

Разделение бандла

Каждый сервис — свой entry point → свой chunk при сборке, браузер грузит только нужное.

Типобезопасность

Shared-пакеты с типами легко шарить между сервисами через monorepo.

Быстрый старт

Не нужно поднимать отдельные репозитории, настраивать легенды и права доступа.

Недостатки монорепозитория с микрофронтами

Категория

Описание

Рост связности

Shared-код может незаметно связывать сервисы, увеличивая coupling.

Общий бандл-сайз

Если не контролировать импорты, можно случайно натащить лишнего в каждый chunk.

Сложность изоляции

Сервисы делят node_modules, что может вызывать конфликты версий зависимостей.

Масштабирование команд

При росте команд более 10 разработчиков появляются конфликты в общем репозитории.

Технический долг

Легаси‑костыли копятся в shared‑пакетах, если не чистить регулярно.

Длительность сборки.

При монолитном CI каждый пуш может триггерить пересборку всех сервисов.

Преимущества truly изолированных микрофронтов

Категория

Описание

Технологическая гибкость

Можно использовать React, Vue, Svelte или другой фреймворк независимо от хоста.

Independent Deploy

Релиз микрофронта не требует развёртывания хоста.

Многократное использование

Один модуль подключается в несколько хост‑приложений без копирования кода.

Independent Scaling

Hot‑path микрофронт масштабируется отдельно от всего приложения.

Fault Isolation.

Падение микрофронта не ложит всё приложение (при грамотной обработке ошибок).

Вызовы при интеграции микрофронта в хост

Категория

Описание

Разные версии зависимостей

Хост и микрофронт могут тянуть разные версии React, что требует shared scope.

Сложность отладки.

Проблемы на стыке хоста и микрофронта труднее диагностировать.

Заключение

Переход на микрофронтовую архитектуру — это стратегическое решение, которое требует тщательного планирования и понимания как преимуществ, так и вызовов.

Ключевые выводы:

  • Микрофронтовая архитектура обеспечивает изоляцию и независимость команд.

  • Монорепозиторий упрощает начальную настройку и поддержку общих библиотек.

  • ES-модули позволяют использовать lazy loading и reduce bundle size.

  • Необходимость чётких контрактов между хостом и микрофронтами.

Рекомендации для команд:

  • Начните с пилотного проекта — выделите один не критичный модуль.

  • Инвестируйте в общие библиотеки и типы с самого начала.

  • Настройте CI/CD для автоматической версионности shared-пакетов.

  • Документируйте контракты между сервисами как часть процесса разработки.

  • Планируйте регулярную очистку shared-пакетов от технического долга.

Спасибо!

Комментарии (0)