Как мы распилили монолит на микрофронтенды с Vite и не сошли с ума
- среда, 18 июня 2025 г. в 00:00:06
Наш фронтенд начинался как простой SPA на React, собранный с помощью Vite — типичный монолит с несколькими страницами. Со временем проект оброс новыми функциями и интеграциями и начал становиться всё сложнее в поддержке.
На горизонте появились новые вызовы: к продукту планировалось подключать всё больше независимых сервисов, а значит — ещё больше интеграций и роста кодовой базы. Мы понимали, что нагрузка на инфраструктуру будет только увеличиваться, поэтому решили заранее заложить архитектуру с расчётом на масштабирование.
После изучения разных вариантов мы остановились на подходе микрофронтендов. Хотелось разграничить зоны ответственности между командами и ускорить разработку, не теряя гибкости. В качестве сборщика решили остаться на Vite — он быстро развивался, предлагал отличную DX и поддержку модульной федерации через плагин. Кроме того, важно было сохранить единый репозиторий, чтобы упростить CI/CD и управление зависимостями.
Подход module federation оправдан, когда приложение становится слишком большим, начинает требовать независимого релиза разных частей и усложняется в сопровождении. Также стоит понимать, что микрофронты — это не только про разбиение кода, но и про инфраструктурные затраты: настройка CI/CD, изоляция окружений, передача состояния, роутинг и, зачастую, потеря некоторых плюсов «единого SPA».
Перед внедрением мы детально проанализировали потенциальные риски: дублирование зависимостей, рост сложности конфигурации, ухудшение DX при локальной разработке (и возможные костыли для улучшения DX), увеличение порога входа в кодовую базу. Взвесив их на фоне ожидаемой пользы — масштабируемости, независимой разработки, упрощения интеграции новых команд — мы пришли к выводу, что плюсы в нашем случае перевешивают.
Так как мы остались на Vite, то использовали активно развивающийся Vite plugin for Module Federation. Его настройка похожа на Webpack Module Federation, хотя на июнь 2025 года у плагина есть свои ограничения, о которых расскажу ниже. Разработку мы начали в тестовом репозитории: проверили работу и в dev-режиме, и в сборке.
Базовая конфигурация host-контейнера и модулей
Пример репозитория, в котором показан переход от монолита к микрофронтендам с использованием Vite и @module-federation/vite. Пример максимально приближен к реальному проекту:
используется общая конфигурация через monorepo,
настроена локальная разработка,
реализована передача состояния и роутинг между микрофронтами.
Репозиторий можно использовать как отправную точку для собственных экспериментов или пилотных внедрений.
Основная часть переезда — это подготовка vite-конфигов для сервисов.
Базовый конфиг для host-контейнера:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { federation } from '@module-federation/vite';
export default defineConfig({
server: {
port: 5173,
origin: 'http://localhost:5173',
},
base: 'http://localhost:5173',
css: {
modules: {
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
plugins: [
react(),
federation({
name: "host",
filename: 'remoteEntry-[hash].js',
manifest: true,
remotes: {
microfront_first: {
type: 'var',
name: 'microfront_first',
entry: 'http://localhost:5174/mf-manifest.json',
entryGlobalName: 'microfront_first',
},
microfront_second: {
type: 'var',
name: 'microfront_second',
entry: 'http://localhost:5175/mf-manifest.json',
entryGlobalName: 'microfront_second',
},
},
exposes: {
"./hooks/useGlobalStore": "./src/hooks/useGlobalStore.ts",
"./hooks/useGlobalStoreSelector": "./src/hooks/useGlobalStoreSelector.ts",
"./store/globalStoreProvider": "./src/store/globalStoreProvider.tsx",
},
shared: ["react"],
}),
],
build: {
target: 'chrome89',
},
});
Базовый конфиг модуля:
import { federation } from '@module-federation/vite';
import react from '@vitejs/plugin-react';
import { defineConfig, type PluginOption } from 'vite';
import { dependencies } from './package.json';
function getSingleReactRefreshPlugin(): PluginOption {
return {
name: 'single-react-refresh',
enforce: 'pre',
transform(code, id) {
if (/\.(js|ts|jsx|tsx)$/.test(id)) {
const updatedCode = code.replace(
/import RefreshRuntime from "\/@react-refresh";/g,
'import RefreshRuntime from "http://localhost:5173/@react-refresh";',
);
return updatedCode;
}
return null;
},
};
}
export default defineConfig({
server: {
port: 5174,
origin: 'http://localhost:5174',
// Настроки hmr (оверлей для микрофронтов - отключен из-за нюансов концепции микрофронтов)
hmr: { overlay: false },
},
base: 'http://localhost:5174',
build: {
target: 'esnext',
},
plugins: [
react(),
getSingleReactRefreshPlugin(),
federation({
filename: 'remoteEntry-[hash].js',
manifest: true,
name: 'microfront_first',
exposes: {
'./MicrofrontFirst': './src/App.tsx',
},
remotes: {
host: {
type: 'var',
name: 'host',
entry: 'http://localhost:5173/mf-manifest.json',
entryGlobalName: 'host',
},
},
shared: {
react: {
requiredVersion: dependencies.react,
singleton: true,
},
},
}),
],
})
На этапе распила монолита и перехода на микрофронтенды мы столкнулись с практическими сложностями. Некоторые из них были связаны с техническими ограничениями инструментария, другие — с организацией процессов разработки в новой архитектуре. Эти проблемы не всегда очевидны на старте, особенно если проект уже живой, со сложившейся инфраструктурой.
В итоге мы выделили ключевые моменты, которые важно учитывать при переезде:
Как управлять состоянием приложения?
Где хранить общие компоненты, хуки, утилиты?
Как организовать локальную разработку с поддержкой HMR?
Как будет устроен деплой?
Ниже разберём каждый из них подробнее.
Хотя идеологически Module Federation предполагает независимость модулей, добиться 100% изоляции сложно, особенно при наличии существующей кодовой базы. Некоторые данные (пользователь, тема, фильтры и т. д.) нужны во всех модулях. Эта информация запрашивается с бэка и помещается в redux-store в host-контейнере.
В этом месте можно придумать множество решений: например избавиться от redux в пользу стейт-менеджеров с концепцией независимых сторов, или прокладывание шины событий между приложениями (event bus). Но для нашего проекта мы выбрали не менять стейт-менеджер (Redux) и воплотить комбинированный подход реализации хранения состояния всего приложения. В host-контейнере создается глобальный стор, который используется в самом host-контейнере и в микрофронтендах только в случае необходимости. И в каждом микрофронте создается собственный стор, который используется и управляется только в рамках модуля.
Какие здесь могут возникнуть проблемы:
Нарушение концепции redux. Она предусматривает наличие одного хранилища в приложении. Но в рамках приложения, где каждый модуль условно независим, эта концепция может быть нарушена. В документации также сказано, что такое решение имеет место быть. Сторы разделяются при помощи пропса “context” в redux provider.
Типизация селекторов и диспатчей. В каждом приложении разворачивается свой redux-store со своими dispatch и selector’s. Чтобы использовать функции из глобального хранилища, мы написали хуки, которые содержат все операции для манипуляций с глобальным хранилищем. Сам host-контейнер, где и находится глобальный стор — это микросервис, который может также выносить наружу функции и компоненты.
Для работы концепции создаются хуки:
useGlobalStore — содержит все диспатчи в глобальном хранилище. useGlobalStoreDispatch — хук для диспатча в глобальный стор.
export default function useGlobalStore() {
const dispatch = useGlobalStoreDispatch();
const incrementGlobalCounter = () => {
dispatch(incrementCounter());
};
const decrementGlobalCounter = () => {
dispatch(decrementCounter());
};
const clearGlobalCounter = () => {
dispatch(clearCounter());
};
return { incrementGlobalCounter, decrementGlobalCounter, clearGlobalCounter };
}
useGlobalStoreSelector — для селекта данных из глобального хранилища.
import { createSelectorHook } from "react-redux";
import type { TypedUseSelectorHook } from "react-redux";
import { GlobalReduxContext, RootState } from "../store";
export const useGlobalStoreSelector: TypedUseSelectorHook<RootState> = createSelectorHook(GlobalReduxContext);
Также нужен глобальный провайдер globalStoreProvider, чтобы иметь доступ к глобальному хранилищу из модулей.
Теперь о сути проблемы. Микрофронты не имеют представления о типах импортируемых компонентов, функций. Поэтому необходимо глобально декларировать типы для этих хуков в каждом микрофронте. Эта проблема не кажется большой, так как зависимостей от глобального модуля должно быть минимальное количество.
declare module "host/hooks/useGlobalStore" {
function useGlobalStore(): {
incrementGlobalCounter: () => void;
decrementGlobalCounter: () => void;
clearGlobalCounter: () => void;
};
export default useGlobalStore;
}
declare module "host/types/storeState" {
export interface CounterState {
value: number;
}
}
declare module "host/hooks/useGlobalStoreSelector" {
import type { CounterState } from "host/types/storeState";
export type RootState = {
counter: CounterState;
};
export interface TypedUseSelectorHook<TState> {
<TSelected>(selector: (state: TState) => TSelected): TSelected;
<Selected = unknown>(selector: (state: TState) => Selected): Selected;
}
export const useGlobalStoreSelector: TypedUseSelectorHook<RootState>;
}
declare module "host/store/globalStoreProvider" {
import React from "react";
type Props = {
children: React.ReactNode;
};
export default function StoreProvider({ children }: Props): JSX.Element;
}
Далее эти хуки/провайдеры используются в микрофронте так, будто они лежат в этом же модуле.
У нас есть собственная дизайн-система и общие компоненты/хуки/функции для работы. Чтобы избежать дублирования, был создан модуль shared, в котором были независимые от хранилища и бэкенда сущности. Модуль подключается через pnpm workspaces, в будущем возможна публикация как npm-пакета. Чтобы избежать дублирования кода в бандлах, модуль объявляется как shared в Vite-конфигурации.
В монорепозитории важным моментом при разработке является HMR. Самая главная часть идеологии Vite — использование нативных модулей в режиме разработки. Это позволяет не билдить при каждом изменении весь бандл, а только файл, в котором произошли изменения. Каждое приложение запускается своим dev-сервером Vite. HMR работает только в пределах одного Vite-Dev-сервера.
Плагин для модульной федерации в vite находится в активной разработке, поэтому решения «из коробки» еще нет. Можно запускать микрофронты в режиме build + watch, но это ломает концепцию локальной разработки: после изменений создается бандл каждого модуля, что, по сути, является собранным проектом. Поэтому, для правильной работы HMR в dev-режиме был написан плагин, который перенаправляет запрос на обновление на host-контейнер.
function getSingleReactRefreshPlugin(): PluginOption {
return {
name: 'single-react-refresh',
enforce: 'pre',
transform(code, id) {
if (/\.(js|ts|jsx|tsx)$/.test(id)) {
const updatedCode = code.replace(
/import RefreshRuntime from "\/@react-refresh";/g,
'import RefreshRuntime from "http://localhost:5173/@react-refresh";',
);
return updatedCode;
}
return null;
},
};
}
Еще одна особенность локальной разработки — обработка ошибок внутри микрофронтов. Dev-сервер Vite работает по WebSocket-соединению: если при разработке возникает ошибка, сервер отправляет её в браузер, и она отображается как overlay поверх страницы. Однако overlay встраивается в index.html, которого у микрофронта (он монтируется в host-приложение), и в этом случае пользователь ничего не увидит.
Чтобы обойти это ограничение, мы добавили обертку ErrorBoundary вокруг микрофронта. Она отлавливает любые runtime-ошибки внутри компонента и самостоятельно отображает UI с описанием ошибки — например, с текстом, стеком вызовов и предложением обновить страницу.
<Route
path={ROUTE_PATH}
element={
<ErrorBoundary fallbackRender={MicrofrontErrorFallback}>
<Suspense fallback={<MicrofrontLoader />}>
<Microfront />
</Suspense>
</ErrorBoundary>
}
/>
Возможно, в будущем с Vite можно будет отображать ошибки без зависимости от index.html, но сейчас альтернативы пока нет.
Фактически, деплой микрофронта не отличается от обычного SPA: сборка выкладывается как статика. Взаимодействие между модулями происходит через mf-manifest.json, указанный в конфигурации.
Переход от монолитного фронтенда к архитектуре микрофронтендов на базе Vite и Module Federation оказался для нас оправданным и успешным решением. Несмотря на трудности, мы завершили полный переход и вывели обновленную архитектуру в прод.
Ключевые преимущества, которые мы получили:
Масштабируемость системы без потери управляемости. Новая архитектура позволила нам легко добавлять новые функции и интеграции, не создавая узких мест в кодовой базе и инфраструктуре. Разделение на независимые модули помогло избежать конфликта между командами и упростило поддержку.
Упрощенный запуск новых микрофронтов. Благодаря выделенным границам ответственности и стандартизированным интерфейсам, мы смогли быстро подключать новые микрофронты в проект без глубокого вмешательства в общий код. В том числе благодаря общей библиотеки компонентов, хуков и функций (shared).
Возможность независимой разработки отдельных модулей. Сервисы получили автономию. Это значительно ускорило цикл разработки.
Архитектурная гибкость и адаптивность под потребности продукта. Мы можем выбирать разные технологии и подходы для отдельных микрофронтов, адаптируя их под задачи, а также проще масштабировать проект с ростом команды и функционала.
При этом стоит учитывать и текущие ограничения:
Некоторые сложности при организации локальной разработки. Из-за особенностей работы HMR с микрофронтендами и отсутствия полноценной поддержки module federation в Vite (в монорепозитории), нам пришлось создавать кастомные решения для корректного обновления кода и отображения ошибок, что увеличивает порог вхождения.
Недостаток готовых решений «из коробки», особенно в области HMR и обработки ошибок. На сегодняшний день доступные плагины и инструменты для module federation находятся в активной разработке и требуют доработок и обходных путей, что накладывает дополнительную ответственность на команду разработчиков.
В целом, мы остались довольны результатом. Vite — это минималистичный, но мощный инструмент, в котором есть всё необходимое для продуктивной работы. Он обеспечивает быструю сборку, а его плагинная архитектура делает интеграцию новых, в том числе кастомных решений, простой и гибкой.
Даже несмотря на несовершенства текущего состояния плагина для модульной федерации, большинство проблем решаются быстро, а активное развитие сообщества вокруг Vite делает этот процесс еще быстрее.