
Если у вас несколько фронтенд-сервисов с одинаковыми компонентами (например, сайдбаром), вы либо уже страдаете, либо скоро начнете. Мы начали страдать на шестом.
Добавить новый пункт меню сайдбара. Казалось бы, пять минут. У нас это было как шесть кругов ада: шесть pull-request’ов, шесть ревью, шесть деплоев и бесчисленное количество запущенных CI-пайплайнов ради одной строчки в навигации.
Начали искать решение. Смотрели разные варианты, остановились на Module Federation. Написали прототип, попробовали пару плагинов, словили пару багов, починили, перенесли в код production-сервисов. С тех пор общий UI-каркас живет и деплоится отдельно от всех сервисов уже два года без каких-либо проблем.
В статье рассматриваем два плагина для подключения Module Federation ретроспективно: на каком этапе развития они были два года назад и что с ними сейчас, как выглядит конфиг настройки на Vite для обоих плагинов и пристально вглядываемся в технические детали и ограничения — местами будет сложно, но вы можете в навигации по статье найти интересующий вас раздел или сразу перейти к практическому примеру.
Весь код из статьи лежит в репозитории articles-module-federation. Там три изолированных прототипа, каждый с рабочим Docker Compose.
Примеры production-сервисов: inner-circle-layout-ui (remote) и inner-circle-books-ui (host).
С чего все началось
Посмотрим на героя нашей статьи: вот он, фронтенд сервиса Inner Circle.

Inner Circle — это наш open source продукт для управления персоналом. С самого начала он строился по модульному принципу: заказчик может задеплоить систему целиком или только необходимые ему модули. Например, использовать только модули библиотеки и тайм-трекера.
Каждый модуль — это самостоятельная пара сервисов: бэкенд и фронтенд, со своими репозиториями и CI/CD-пайплайнами. На фронтовой стороне это реализовано через микрофронтенды: независимые приложения, объединённые общим UI-каркасом (хедер, футер, сайдбар). Этот каркас мы называем layout.

Когда сервисов было два-три, все держалось на копировании. У каждого сервиса была своя копия layout.
К переосмыслению архитектуры нас подтолкнули два события одновременно. На подходе был седьмой сервис, значит, ещё одна копия Layout и еще одно место, куда катить обновления. И параллельно встал вопрос о переходе с Webpack на Vite и с React 17 на React 18. Vite к тому моменту уже устоялся на рынке, давал заметный прирост скорости сборки и производительности в разработке, а отказ от поддержки старых браузеров позволил наконец перестать тянуть ES5-совместимость. Раз уж все равно меняем стек, то решили заодно избавиться от накопившейся боли с дублированием Layout.
Давайте зафиксируем наше «до», чтобы в конце статьи сравнить его с «после»:
Процесс выкатки одного изменения в Layout:
6 PR -> 6 ревью -> 6 деплоев на одну строчку в навигации
Время от мерджа до появления изменения во всех сервисах: часы, иногда день
Нужно вручную отслеживать выполнение всех 6 деплоев и проверять, что все сервисы подняли одну и ту же версию Layout, чтобы избежать рассинхрона в production
Состояние архитектуры:
6 копий layout в 6 репозиториях
Правки в одном модуле не попадают в остальные автоматически, только вручную
Стоимость подключения 7-го сервиса: еще одна копия и еще одно место, куда катить обновления
Какие варианты существуют для решения нашей проблемы?
npm-пакет
Самый очевидный путь. Выносим layout в отдельный пакет, публикуем в registry, подключаем во всех сервисах как зависимость. Но это не решает проблему: при каждом изменении layout нужно публиковать новую версию, потом вручную обновлять зависимость в каждом сервисе, пересобирать их и деплоить все сервисы в production. При шести, а скоро и семи сервисах и активной разработке layout это превращается в постоянную координацию. Именно то, от чего мы хотели уйти.
Монорепо
Тоже рассматривали. Это упростило бы работу с кодом, уменьшило количество PR и сделало изменения между сервисами удобнее. Но в нашем пайплайне это не решало главную проблему: изменение общего layout всё равно потребовало бы пересборки и редеплоя всех сервисов, которые его используют. Наша цель была другой: обновлять layout независимо от остальных фронтендов и не трогать их сборку и деплой.
Монолитный фронтенд
Тоже не вариант. Изначально архитектура Inner Circle строилась вокруг модульности: разные части продукта должны были развиваться и деплоиться независимо друг от друга.
Single SPA
Фреймворк для микрофронтендов, который монтирует независимые приложения на одной странице. Его отличительная черта — поддержка разных фреймворков даже на одной странице. У нас везде был React, так что изоляция фреймворков была не нужна, а полноценный оркестрационный слой (Lifecycle API), который обязательно настраивать для каждого приложения, был избыточен.
Module Federation
Позволяет одному сервису выставить свой код наружу через exposes, а другому подключить его в рантайме через remotes. Никакой пересборки хостов: layout задеплоился, и все микрофронтенды видят его новую версию при следующей загрузке страницы. Именно это нам и было нужно.
Как работает Module Federation
Module Federation (MF) — это механизм, позволяющий разным JS-сборкам делиться кодом в рантайме. Каждое приложение может быть remote (поставщик модулей), host (потребитель) или обоими одновременно.
Схема работы:
Remote при сборке генерирует специальный entry-файл. По умолчанию — remoteEntry.js, но имя настраивается. В наших конфигах это
layout.js, который рассмотрим позже. В нем описано, какие модули выставлены наружу и где их взять.Host при запуске динамически загружает этот entry-файл по указанному URL. После этого мы можем импортировать нужные модули через обычные импорты.
Shared-зависимости (react и т. д.) могут переиспользоваться и не дублироваться между remote и host. Это работает, если версии зависимостей совпадают или совместимы по semantic versioning.
Важный момент: host не включает код remote в свой bundle на этапе сборки. Связь происходит только в рантайме. Именно поэтому изменение в remote не требует пересборки host.
Сам механизм появился в Webpack 5 и стал одним из самых распространённых подходов к микрофронтендам. В Vite встроенной поддержки нет, её добавляют плагины. Основных два:
@module-federation/vite — официальный плагин, живёт в организации module-federation;
@originjs/vite-plugin-federation — популярный сторонний плагин.
Дальше в статье сравним их в двух срезах: почему два года назад мы выбрали @originjs и как оба плагина выглядят сегодня, а затем разберём полные конфигурации каждого.
Из чего состоит URL
Здесь и далее в статье встречаются термины origin, hostname, port, path — разберём их на одном примере.

scheme (
http) + hostname (localhost) + port (4000) вместе образуют origin. Именно origin браузер сравнивает, когда решает, «свой» это сервер или «чужой». На этом сравнении, например, строится CORS.path (
/layout/assets/layout.js) — всё, что идёт после порта и до?queryи#fragment, если они есть.
Когда каждый сервис при локальной разработке поднимается на своём порту (localhost:4000, localhost:4001, localhost:4002), hostname у них совпадает, а port и, соответственно, origin — разные. В Docker Compose и в production все сервисы стоят на одном origin (example.com), а различаются только path (/layout, /host-a, /host-b).
Сервисы в прототипе
Дальше в примерах постоянно всплывают три имени: layout, host-a, host-b. Это сервисы из прототипов репозитория: layout — remote с общим UI-каркасом, а host-a и host-b — это два независимых демо-приложения, которые его подключают. У каждого свои страницы (Home и About у host-a, Dashboard и Reports у host-b), но хедер и сайдбар оба берут у layout.

Связь односторонняя: host-a и host-b знают про remote layout в своём конфиге, а layout про них — нет (кроме раздела про динамическую навигацию ниже).
Архитектура: path-based vs domain-based
Прежде чем разбирать конфиги, важно понять архитектурный выбор, который влияет на всё остальное.
Есть два подхода к размещению микрофронтендов:
Domain-based: каждый микрофронтенд живёт на своём поддомене: layout.example.com, books.example.com.
Path-based: все микрофронтенды живут на одном домене, каждый микрофронтенд живет на своем path: example.com/layout, example.com/books.
В production и в Docker Compose у нас path-based: все сервисы за одним nginx, один origin.
При локальной разработке без Docker каждый сервис на своём порту. С точки зрения браузера это фактически три независимых сервера — то есть фактически domain-based, несмотря на общий hostname.
Именно поэтому в прототипах два .env-файла: .env.domain-based с абсолютными URL для локального запуска (например, http://localhost:4000/layout/assets/layout.js в прототипе на @originjs — это полный URL, содержащий origin: схему, хост и порт) и .env.path-based с относительными путями (/layout/assets/layout.js) для Docker и production. Названия файлов завязаны на архитектурный подход, а не на среду запуска: .env.path-based — это не только «настоящий» production, тот же файл целиком отвечает и за локальный запуск через Docker Compose, ведь там сервисы точно так же находятся на одном домене. Содержимое .env-файлов зависит от плагина и разбирается ниже, когда дойдём до конфигов. Выбор архитектуры определяет дальнейшую конфигурацию.
Основные поля конфигурации
Разберём поля vite.config, которые используются в конфигах ниже.
base — публичный базовый путь, который Vite добавляет в начало ссылок на статику и пути в собранном HTML. При base: '/layout' импорт /assets/logo.png разрешится в /layout/assets/logo.png. Это Absolute URL pathname (документация) — путь без схемы и хоста, то есть относительный. В path-based архитектуре нужен именно он: URL с доменом привязал бы ассеты к конкретному хосту, и сборка сломалась бы при смене окружения.
server.port — порт запуска vite dev-сервера (по умолчанию 5173, см. в документации).
server.origin — адрес (схема, хост, порт), который Vite подставляет в начало URL ассетов в dev-режиме (документация). Без него при локальной разработке с несколькими портами будет разлад: браузер станет искать ассеты remote-сервиса на порту текущей вкладки, а не там, где они реально отдаются. Причина в том, что port и origin — разные настройки: port — это где слушает dev-сервер, origin — какой адрес Vite вставляет в сгенерированные ссылки на ассеты. Без origin эти ссылки получаются относительными, а относительный URL браузер всегда резолвит от адреса открытой сейчас страницы, то есть от порта host, а не remote.
Поле работает только в dev-режиме, при build (production-режим) оно не используется. Оно нужно только плагину @module-federation/vite, который в dev-режиме строит publicPath remote-сервиса как server.origin + base.
name — уникальное имя приложения в рамках MF-системы. Рантайм использует его как ключ, по которому регистрируются и находятся контейнеры модулей, а webpack в классическом var-формате ещё и как имя глобальной переменной контейнера — поэтому там дефис в имени ломает сборку. В ESM-режимах обоих Vite-плагинов дефис работает, но мы везде используем подчёркивания (host_a): единое правило страхует при возможном подключении с webpack-remote и даёт один стиль для name и ключей remotes.
filename — имя entry-файла, который remote генерирует при сборке. Это тот файл, на который указывает host в remotes. Обратите внимание: итоговый URL этого файла у плагинов разный. @module-federation/vite кладёт его в корень сборки (/layout/layout.js), а @originjs — в папку assets (/layout/assets/layout.js).
exposes — объект { "путь_импорта": "файл" }: модули, которые сервис выставляет наружу. Параметр remote-сервиса. Запись './layout': './src/Layout.tsx' выставляет компонент под путём ./layout, и host импортирует его как layout/layout. Две части этого импорта берутся из разных мест: первая (layout) — ключ, под которым host записал remote в своём remotes (у нас он совпадает с name remote, но это именно ключ из remotes), вторая — путь ./layout из exposes без ./. Хорошо видно на примере из раздела про динамическую навигацию: host-a выставляет ./nav, и layout импортирует его как host_a/nav — первая часть из remotes, вторая — из exposes.
shared — общие зависимости между host и remote, чтобы не грузить их дважды. Поддерживаются две формы: массив (['react']) и объект с дополнительными параметрами, например singleton. Подробно про shared, включая то, что шарить обязательно, а что нет, разбираем в отдельном разделе ниже.
build.target задаёт, под какой стандарт JavaScript Vite собирает код: какие современные конструкции оставить как есть, а какие преобразовать в более старый синтаксис. Для Module Federation на Vite важно одно: сгенерированный плагинами код использует top-level await, поэтому таргет должен его поддерживать. Top-level await появился в ES2022 и поддерживается с Chrome/Edge/Firefox 89 и Safari 15 (таблица совместимости). Два года назад, на Vite 4, дефолтный таргет (‘modules’, примерно Chrome 87) его не поддерживал, сборка падала с ошибкой esbuild «Top-level await is not available in the configured target environment», а это требование в документации плагинов было заметно не сразу. В Vite 7 дефолт подняли до ‘baseline-widely-available’ (Chrome 107+, документация), а в Vite 8 планка «baseline» поднялась ещё выше — до Chrome 111 / Firefox 114 / Safari 16.4 (по состоянию Baseline Widely Available на 01.01.2026), и из коробки всё собирается. Мы всё равно оставляем target: 'esnext' явно: это документированное требование обоих плагинов, и оно защищает от случайного даунгрейда таргета в будущем.
Выбор плагина: почему официальный не подошел
Два года назад мы начали с официального @module-federation/vite плагина.
Вот как выглядел конфиг layout незадолго до того, как мы сдались
import { federation } from '@module-federation/vite' import path from 'path' export default defineConfig({ server: { origin: `http://localhost:40100`, port: 40100, }, base: `/layout`, plugins: [ federation({ name: `inner_circle_layout_ui`, filename: 'remoteEntry.js', manifest: true, exposes: { './layout': path.resolve(__dirname, 'src/App.tsx'), }, shared: { react: { singleton: true, requiredVersion: '^18.2.0', }, 'react-dom': { singleton: true, requiredVersion: '^18.2.0', }, }, }), ], build: { target: `chrome89`, rollupOptions: { external: [ `react`, 'react-dom', `react/jsx-runtime`, 'virtual:*', /^__mf__virtual\//, ], }, }, })
Почти каждое поле в этом конфиге появилось как заплатка. Единственное исключение — manifest: true, разберём его сразу, чтобы не возвращаться.
Он кладёт рядом с entry-файлом при сборке JSON-описание mf-manifest.json: что модуль выставляет (exposes), что ему нужно от других remote (remotes), какие shared-зависимости и в каких версиях доступны, плюс метаданные сборки (полный список полей). Для самой загрузки remote манифест не нужен: entry-файл самодостаточен, в нем уже есть и код, и вся эта информация.
У манифеста два применения. Если в remotes указать его вместо entry, заработает preloadRemote: прицельная предзагрузка ассетов remote по данным из манифеста, а не по факту первого обращения к модулю. И его читают внешние инструменты вроде Chrome DevTools для MF: без манифеста в расширении не работают визуализация графа зависимостей, проверка singleton/strictVersion и проксирование remote на локальную версию для отладки без перезагрузки.
Манифест собирается через анализ ассетов сборки, и в больших проектах это заметно увеличивает время сборки — документация прямо предупреждает об этом. В конфигах ниже и в прототипах его нет: это минимальные конфигурации, пример для повторения, а не полный production-набор со всеми опциями. Если понадобится preload или отладка через Chrome DevTools — manifest: true добавляется одной строкой, ничего не сломается.
Теперь заплатки. rollupOptions.external появился потому, что без него Docker-сборка падала. target: 'chrome89' — потому что на Vite двухлетней давности сборка с дефолтным таргетом падала на top-level await, а требование мы нашли не сразу. path.resolve вместо строки — потому что без него entry-файл генерировался некорректно. Параллельно чинили пути к CSS-чанкам в production через cssCodeSplit, assetFileNames, resolve.alias. Отдельным пунктом шли проблемы с загрузкой remote из Docker-окружения и CORS при локальной разработке на разных портах.
Каждая из этих проблем по отдельности решаема, но каждое новое решение порождало следующую проблему: конфиг обрастал слоями, которые маскировали симптомы, а не лечили причину. Стало понятно, что @module-federation/vite в нашей конфигурации требует значительно больше ручной подгонки, чем @originjs/vite-plugin-federation, который заработал без особого сопротивления.
Весь его конфиг для того же remote
import federation from '@originjs/vite-plugin-federation' export default defineConfig({ base: `/layout`, plugins: [ react(), federation({ name: "inner_circle_layout_ui", filename: "inner_circle_layout_ui.js", exposes: { "./layout": "./src/Layout.tsx", }, shared: [ "react", "react-dom", ], }), ], build: { target: "esnext", }, })
У @originjs есть своя цена, о которой стоит знать заранее: он не генерирует remote entry в dev-режиме. Remote нельзя запустить через vite dev, только собрать (vite build) и отдавать через vite preview — в документации плагина это сформулировано как «Only the Host side supports dev mode». README плагина советует смягчить это через vite build --watch: сборка remote перезапускается сама при каждом изменении кода, а уже запущенный vite preview отдаёт новый билд на следующий запрос без собственного перезапуска — остаётся обновить страницу в браузере (полноценного hot reload это не даёт, вкладка сама не обновится). Мы в production-layout добавили скрипт для запуска в этом режиме, а host-сервисы, где идёт основная разработка, работают в обычном dev-режиме с hot reload. Если вы планируете активно разрабатывать сам remote, build --watch избавит от ручных пересборок — без него это заметное неудобство. У @module-federation/vite такого ограничения нет.
В общем, два года назад мы решились на @originjs в production и не пожалели: всё работало хорошо. Но давайте посмотрим, как обстоят дела с плагинами сейчас.
Актуальное состояние поддержки
@module-federation/vite с тех пор сильно вырос: релизы выходят несколько раз в месяц (актуальная версия на момент написания статьи — 1.23.0 от 28.09.2026), документация стала значительно лучше, добавились возможности, которых нет у стороннего плагина — в частности, автоматическая генерация TypeScript-типов для remote-модулей. Для статьи мы протестировали его заново, и он заработал без проблем, которые были два года назад. Для новых проектов сегодня его стоит рассматривать в первую очередь.
@originjs/vite-plugin-federation стабильно работает и всё еще массово используется: на момент написания (сентябрь 2026) у него порядка 260 тысяч скачиваний в неделю против примерно 700 тысяч у официального. Но темп разработки заметно снизился: последний релиз, 1.4.1, вышел в апреле 2025 года. Для существующих проектов, которые уже работают на нём, переход необязателен: базовые сценарии он закрывает хорошо. Закладываться на него в новых проектах мы бы уже не стали.
В репозитории articles-module-federation лежат рабочие прототипы для обоих плагинов, можно сравнить самостоятельно.
Конфигурация для плагина @originjs/vite-plugin-federation
Remote (сервис layout из репозитория)
import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import federation from '@originjs/vite-plugin-federation' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { port: Number(env.LAYOUT_PORT), }, base: '/layout', plugins: [ react(), federation({ name: 'layout', filename: 'layout.js', exposes: { './layout': './src/Layout.tsx', }, shared: ['react', 'react-dom'], }), ], build: { target: 'esnext', }, } })
Host (сервис host-a из репозитория)
import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import federation from '@originjs/vite-plugin-federation' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { port: Number(env.HOST_PORT), }, base: '/host-a', plugins: [ react(), federation({ name: 'host_a', remotes: { layout: env.LAYOUT_URL, }, shared: ['react', 'react-dom'], }), ], build: { target: 'esnext', }, } })
В @originjs remotes — это просто { имя: url }. Первый вариант — абсолютный URL для локальной разработки, значение LAYOUT_URL из .env.domain-based:
LAYOUT_URL=http://localhost:4000/layout/assets/layout.js
Второй вариант — относительный путь для Docker или production, из .env.path-based:
LAYOUT_URL=/layout/assets/layout.js
Во втором варианте в URL нет ни схемы, ни хоста, только путь: /layout/assets/layout.js. Браузер сам подставляет адрес текущей страницы. В production и в Docker Compose host-a, host-b и layout стоят за одним nginx, поэтому запрос уходит на тот же адрес, что и сама страница, только по другому пути. nginx видит префикс /layout/... и перенаправляет такие запросы на контейнер layout-сервиса — это правило прописано в nginx-proxy.conf (подробнее про эту схему — в разделе «Итоговая архитектура» ниже). Благодаря этому один и тот же собранный образ работает в любом окружении.
Обратите внимание на
/assets/в пути:@originjsкладёт entry-файл в папку assets сборки, а не в корень. Если указать/layout/layout.jsпо аналогии с официальным плагином, получите 404 на remote entry.
Конфигурация для плагина @module-federation/vite
Remote (layout)
import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import { federation } from '@module-federation/vite' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { port: Number(env.LAYOUT_PORT), origin: env.ORIGIN, }, base: '/layout', plugins: [ react(), federation({ name: 'layout', filename: 'layout.js', exposes: { './layout': './src/Layout.tsx', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true }, }, }), ], build: { target: 'esnext', }, } })
Host (host-a)
import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import { federation } from '@module-federation/vite' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { port: Number(env.HOST_PORT), origin: env.ORIGIN, }, base: '/host-a', plugins: [ react(), federation({ name: 'host_a', remotes: { layout: { type: 'module', name: 'layout', entry: env.LAYOUT_URL, }, }, shared: { react: { singleton: true }, 'react-dom': { singleton: true }, }, }), ], build: { target: 'esnext', }, } })
Здесь entry-файл лежит в корне сборки, поэтому LAYOUT_URL без /assets/: http://localhost:4000/layout/layout.js локально и /layout/layout.js для Docker/production.
В @module-federation/vite remotes — объект с type, name и entry. Поле type задаёт, как рантайм загрузит remote: ‘module’ (или его синоним ‘esm’) включает загрузку через динамический import(). Если type не указать, плагин подставит ‘var’ — тип, рассчитанный на классический скрипт, который кладёт remote в глобальную переменную. Vite же собирает entry-файл в формате ESM с import/export внутри. Такой код нельзя выполнить как обычный скрипт, поэтому для Vite нужен именно type: 'module'. Формально ‘var’ с Vite-remote тоже возможен, но только если remote дополнительно генерирует классический entry через опцию varFilename; для стандартной ESM-сборки без него единственный рабочий вариант — type: 'module'.
Про переменную ORIGIN в server.origin. Она нужна только в dev-режиме, и у каждого приложения она своя: в .env.domain-based это http://localhost:4000 у layout, http://localhost:4001 и http://localhost:4002 у хостов. При сборке server.origin не используется, поэтому в .env.path-based этого прототипа стоит заглушка /. Один нюанс: в прототипе с динамической навигацией та же переменная называется VITE_ORIGIN, и там её значение уже не произвольное, потому что её дополнительно читает клиентский код. Подробности в следующем разделе.
Сравнение синтаксиса remotes
// @originjs/vite-plugin-federation - строка remotes: { layout: '/layout/assets/layout.js', } // @module-federation/vite - объект remotes: { layout: { type: 'module', name: 'layout', entry: '/layout/layout.js', }, }
У @originjs есть аналог type — параметр format (‘esm’ | ‘systemjs’ | ‘var’), но в короткой записи { имя: url } он не указывается и по умолчанию равен ‘esm’, который нам подходит.
Динамическая навигация
Оба плагина поддерживают bidirectional federation: приложение может быть одновременно и remote, и host. Достаточно указать и exposes, и remotes в одном конфиге.
В нашем прототипе module-federation-vite каждый host сам выставляет свои пункты меню через MF, а layout загружает их в рантайме.
host-a выставляет свою навигацию:
vite.config.ts в host-a
export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { port: Number(env.HOST_PORT), origin: env.VITE_ORIGIN, }, base: '/host-a', plugins: [ react(), federation({ name: 'host_a', remotes: { layout: { type: 'module', name: 'layout', entry: env.LAYOUT_URL }, }, exposes: { './nav': './src/nav.ts', }, filename: 'host-a.js', shared: { react: { singleton: true }, 'react-dom': { singleton: true }, }, }), ], build: { target: 'esnext', }, } })
src/nav.ts
export const BASE = `/host-a` export const origin = import.meta.env.VITE_ORIGIN export const navItems = [ { label: `Home`, path: `${BASE}/` }, { label: `About`, path: `${BASE}/about` }, ]
Здесь и появляется переменная VITE_ORIGIN вместо ORIGIN. Vite отдаёт клиентскому коду только переменные с префиксом VITE_ (документация), а nav-модулю origin нужен в рантайме: layout строит по нему абсолютные ссылки на чужие хосты при локальной разработке на разных портах. Одна переменная работает и с server.origin, и с nav-модулем. Поэтому в .env.path-based она не «любая», а осознанно пустая: ссылки становятся относительными, и тот же образ работает за nginx в любом окружении.
Используйте переменные с префиксом VITE_ аккуратно, ведь они будут доступны в исходном коде на стороне клиента.
layout динамически загружает навигацию от всех хостов:
vite.config.ts в layout
federation({ name: 'layout', filename: 'layout.js', exposes: { './layout': './src/Layout.tsx' }, remotes: { host_a: { type: 'module', name: 'host_a', entry: env.HOST_A_URL }, host_b: { type: 'module', name: 'host_b', entry: env.HOST_B_URL }, }, shared: { react: { singleton: true }, 'react-dom': { singleton: true }, }, })
useHostNav.ts
export function useHostNav(): NavItemWithOrigin[] { const [navItems, setNavItems] = useState<NavItemWithOrigin[]>([]) useEffect(() => { Promise.allSettled([ import(`host_a/nav`) as unknown as Promise<NavModule>, import(`host_b/nav`) as unknown as Promise<NavModule>, ]).then(results => { const modules = results .filter( (result): result is PromiseFulfilledResult<NavModule> => result.status === 'fulfilled' ) .map(result => result.value) setNavItems( modules.flatMap(module => module.navItems.map(item => ({ ...item, origin: module.origin })) ) ) }) }, []) return navItems }
Promise.allSettled здесь не случаен: с Promise.all падение одного хоста обрушило бы загрузку всей навигации разом. С allSettled из сайдбара выпадут только пункты недоступного хоста.
Что дает этот подход: пункты меню существующего хоста меняются правкой в самом хосте, layout пересобирать не нужно — он подтянет новый nav-модуль при следующей загрузке страницы. Подключение нового хоста, впрочем, все еще требует правки layout: добавить его в remotes и в useHostNav и передеплоить.
Вероятно, вы задумаетесь: какой толк от динамической подгрузки, если layout все равно что-то нужно знать о хостах? Отличия между подходами не в том, знает ли layout про хосты, ведь URL он знает в обоих случаях. Важно то, кто владеет списком пунктов меню. При bidirectional federation каждый host сам решает, что показать в сайдбаре: добавить или переименовать пункт в меню — будет являться правкой в host, layout лишь агрегирует чужие данные. При статической навигации layout хранит список пунктов сайдбара.
В нашем production-решении (inner-circle-layout-ui) мы выбрали статическую навигацию: сервисов не так много, их список меняется редко, а единый источник правды в layout упрощает поддержку. Какой подход выбрать, зависит от того, кому должно принадлежать право определять навигацию: host или layout. Возможно, скоро мы изменим своё решение.
Использование в коде host
Одинаково для обоих плагинов. Remote-модуль можно импортировать статически — так же, как обычный компонент:
import Layout from 'layout/layout' import { appRoutes } from './pages/routes' export default function App() { return <Layout routes={appRoutes} /> }
При желании remote-компонент можно завернуть в lazy():
import { Suspense, lazy } from 'react' import { appRoutes } from './pages/routes' const Layout = lazy(() => import('layout/layout')) export default function App() { return ( <Suspense fallback={<div>Loading layout…</div>}> <Layout routes={appRoutes} /> </Suspense> ) }
Со статическим импортом, пока remote-модуль не загружен, приложение не отрисуется. С lazy() host сразу показывает fallback и дорисовывает Layout, когда тот подгрузится.
Учтите: если remote так и не загрузится, lazy() без Error Boundary оставит пользователя наедине с вечным fallback, поэтому в production оборачивайте такой импорт в Error Boundary с осмысленной обработкой.
В прототипах originjs и module-federation-vite-static-nav хосты используют статический импорт, в прототипе module-federation-vite используется вариант с lazy(). Production-хосты импортируют layout статически.
Shared-зависимости: что обязательно, что опционально
shared нужно прописывать зеркально в конфиге host-сервиса. Если в remote указан shared: ['react'], но в host это поле отсутствует, React будет загружен дважды — по одной копии из каждого бандла.
react — шарить обязательно. Без него на странице окажется два независимых экземпляра, и хуки сломаются с ошибкой вида Cannot read properties of null (reading 'useMemo'). React прямо документирует это ограничение: для работы хуков компонент и рендерящий его react-dom должны видеть один и тот же модуль react (Invalid Hook Call, Duplicate React).
react-dom — идёт в паре с react. Если его не расшарить, вторая копия из remote просто лежит мёртвым грузом: рендерит страницу всё равно host react-dom, но лишние сотни килобайт в бандле и потенциальные проблемы при обновлении React остаются.
Библиотеки с React-контекстом — легко упустить. Контекст привязан к экземпляру модуля: у двух копий библиотеки два разных контекста, и провайдер одной копии невидим для хука из другой. Так себя ведёт react-router-dom: BrowserRouter рендерит layout, и если host-страница вызовет <Link> или useNavigate() из собственной копии роутера, она не найдёт Router и упадёт с ошибкой. Коварство в том, что до первого использования роутера на host-странице всё выглядит рабочим.
В наших прототипах роутер использует только layout, поэтому его нет в shared: копия одна, конфликтовать не с чем. Если понадобится роутер страницам хостов, то добавьте его в shared во все конфиги, у официального плагина — с singleton: true.
Остальные зависимости — по ситуации. Общий принцип: шарить стоит то, что держит глобальное состояние или контекст и должно существовать в единственном экземпляре. Всё остальное безопаснее оставить в бандле каждого сервиса, особенно если разные сервисы тянут одну библиотеку в разных версиях через транзитивные зависимости.
Про singleton: true у @module-federation/vite. Опция означает «во всём приложении должен существовать ровно один экземпляр модуля»; для React это ровно то, что нужно. Важно понимать, что происходит при расхождении версий: рантайм не падает, а выводит предупреждение в консоль и загружает более высокую из имеющихся версий; предупреждение получает сторона с более низкой версией (документация shared). Задать singleton через shared в виде массива нельзя — нужна объектная запись:
shared: { react: { singleton: true }, }
У @originjs опции singleton нет. Плагин просто переиспользует уже загруженную копию, если версия проходит проверку requiredVersion. Там достаточно shared в виде массива, одинакового в host и remote.
Порядок запуска при локальной разработке
Браузер, открывая host-страницу, пытается загрузить entry-файл remote. Если remote в этот момент не запущен, host-страница не работает. Поэтому порядок важен: сначала remote, потом host-сервисы. Для @originjs remote к тому же нужно не запустить, а собрать и поднять через preview (см. раздел про выбор плагина).
Как именно проявляется сбой, зависит от способа импорта. Статический импорт remote-компонента (как Layout в App.tsx хостов originjs-прототипа) блокирует выполнение всего модуля: первый рендер не происходит, пользователь видит белый экран и ошибку в консоли. Вариант с lazy() без Error Boundary отрисует host и навсегда застрянет на fallback «Loading layout…».
В bidirectional federation (см. раздел про динамическую навигацию) картина лучше: useHostNav вызывает import('host_a/nav') внутри useEffect, уже после первого рендера Layout. Если host_a в этот момент недоступен, его промис зареджектится, но React-дерево уже отрисовано: благодаря Promise.allSettled из сайдбара выпадут только пункты этого хоста, а сообщение об ошибке появится в консоли.
Обратно пункты сами не вернутся: nav-модули запрашиваются один раз при монтировании Layout, и когда host поднимется, его ссылки появятся только при следующей загрузке страницы. В path-based архитектуре это не проблема: переход в другой сервис — это обычная ссылка с полной загрузкой страницы, так что навигация обновится при первом же переходе. Если нужен живой ретрай без перезагрузки, у официального MF есть @module-federation/retry-plugin, который автоматически повторяет загрузку упавших remote-модулей.
Итоговая архитектура
Как вы помните, у нас два года в production плагин @originjs/vite-plugin-federation. Там схема работы выглядит так:

Layout-ui и books-ui никогда не общаются друг с другом напрямую. Books-ui знает адрес layout только из своего конфига (remotes: { layout }), а сами модули по этому адресу загружает браузер — обычными HTTP-запросами через nginx.
Как это работает в рантайме:

Layout физически существует и деплоится отдельно, но визуально работает как часть host-приложения.
При этом:
Все микрофронтенды можно деплоить независимо
Host не нужно пересобирать при изменениях layout
Новый сервис подключается парой строк в конфиге и одним импортом (плюс location в nginx и, при статической навигации, пункт меню в layout)
CORS не беспокоит
Решение работает стабильно 2 года без инцидентов
Таким образом, наше «до» и «после»:
До | После | |
|---|---|---|
Выкатка одного изменения в Layout | 6 PR -> 6 ревью -> 6 деплоев | 1 PR -> 1 ревью -> 1 деплой |
Изменение появляется во всех сервисах | через часы, иногда день | при следующей загрузке страницы (если entry-файл layout не закэширован браузером, см. «Известные ограничения» ниже) |
Пересборка host-сервисов при изменении Layout | все 6 пересобираются и деплоятся | не нужна: host-сервисы не знают о деталях Layout |
Контроль рассинхрона в production | вручную следить, что все 6 деплоев прошли и версии совпали | следить не за чем: версия Layout одна |
Копии layout | 6 копий в 6 репозиториях | одна, в своём репозитории, деплоится независимо |
Подключение нового сервиса | ещё одна копия и ещё одно место, куда катить обновления | без новой копии: пара строк в конфиге и импорт |
Известные ограничения
Решение работает стабильно, но два момента стоит держать в голове.
Кэширование remote entry. У entry-файла стабильное имя без хэша: без явных заголовков кэширования браузер может эвристически сохранить в кэше и какое-то время после деплоя показывать старую версию (MDN про HTTP-кэширование). Решение: no-cache на entry-файл, immutable с долгим max-age на хэшированные чанки; стоит только перепроверить, что заголовок реально доходит до нужного location в nginx.
Шрифты. Layout подключает свои шрифты самостоятельно, но если host-сервис использует другой шрифт и не подгружает шрифт Layout явно, тот унаследует шрифт сервиса. Лечится подключением нужного шрифта в host, либо, вероятно, шарингом стилей через Layout как отдельный exposed-модуль. Мы второй вариант пока не реализовали.
Полный чеклист для Module Federation на Vite
Конфигурация
build.target поддерживает top-level await: esnext или таргеты не ниже Chrome/Edge/Firefox 89, Safari 15. На Vite 7 дефолт уже подходит, на старых версиях без явного таргета сборка упадёт
base — абсолютный путь без хоста (
/layout), не полный URLДля
@module-federation/vite:type: 'module'в remotes, server.origin для dev-режимаДля
@originjs: URL entry-файла содержит/assets/; у официального плагина файл в корне сборкиДля
@originjsучтено, что remote в dev-режиме не работает: сначала build + preview, потом хосты (илиvite build --watch+ preview для автоматической пересборки)
Shared
Нужные shared прописаны с обеих сторон: общей становится только зависимость, объявленная и в host, и в remote
react шарится всегда (для
@module-federation/vite— сsingleton: true)react-dom обычно шарится вместе с react — иначе лишние сотни килобайт в бандле и риски при обновлении версии React, но обязательности здесь нет
Всё, что держит контекст или глобальное состояние (роутер, стейт-менеджер, тема), шарится, если пересекает границу host и remote
Версии shared-зависимостей совместимы между сервисами; предупреждения MF в консоли не игнорируются
Вывод
Мы прошли путь от шести копий layout и шести деплоев на одну измененную строку до одного репозитория и одного деплоя. Module Federation оказался ровно тем инструментом, который решает нашу задачу: host-сервисы не пересобираются при изменениях общего каркаса, а новая версия layout доезжает до пользователей при следующей загрузке страницы.
Главные уроки, которые мы вынесли:
Выбор плагина не догма. Два года назад с официальным плагином у нас не получилось, и мы ушли на
@originjs. Сегодня@module-federation/viteзрелый, активно развивается, и для новых проектов мы бы начинали с него.Архитектурные решения (path-based против domain-based, статическая навигация против динамической) важнее конфигов: они определяют, кто владеет данными и что придётся передеплоивать при изменениях.
Не стоит бояться Module Federation и его настройки: может показаться, что это очень сложно и болезненно, но на деле грабли можно поймать в паре мест. В этой статье мы постарались описать и объяснить всё, с чем вы можете столкнуться, по нашему опыту. Надеемся, чеклист и вся информация выше помогут вам прийти к удобной конфигурации ваших сервисов.
Если у вас несколько сервисов с общим UI-каркасом и вы всё ещё копируете его руками — попробуйте. Три готовых прототипа с Docker Compose лежат в репозитории, их можно поднять локально за несколько минут и потрогать оба плагина. Вопросы и опыт с вашими граблями приносите в комментарии.
Полезные ссылки
Репозитории с примерами
articles-module-federation — три прототипа из статьи
inner-circle-layout-ui — production Remote (layout)
inner-circle-books-ui — production Host
Плагины
Документация
Vite: server.origin, base, build.target, переменные окружения и префикс VITE_
Module Federation: shared, retry-plugin
React: Invalid Hook Call и дубликаты React, lazy() и обработка ошибок загрузки
Совместимость браузеров: top-level await
MDN: HTTP-кэширование

