Если у вас несколько фронтенд-сервисов с одинаковыми компонентами (например, сайдбаром), вы либо уже страдаете, либо скоро начнете. Мы начали страдать на шестом.

Добавить новый пункт меню сайдбара. Казалось бы, пять минут. У нас это было как шесть кругов ада: шесть 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
Фронтенд Inner Circle

Inner Circle — это наш open source продукт для управления персоналом. С самого начала он строился по модульному принципу: заказчик может задеплоить систему целиком или только необходимые ему модули. Например, использовать только модули библиотеки и тайм-трекера.

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

layout: хедер, футер и сайдбар
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 (потребитель) или обоими одновременно.

Схема работы:

  1. Remote при сборке генерирует специальный entry-файл. По умолчанию — remoteEntry.js, но имя настраивается. В наших конфигах это layout.js, который рассмотрим позже. В нем описано, какие модули выставлены наружу и где их взять.

  2. Host при запуске динамически загружает этот entry-файл по указанному URL. После этого мы можем импортировать нужные модули через обычные импорты.

  3. Shared-зависимости (react и т. д.) могут переиспользоваться и не дублироваться между remote и host. Это работает, если версии зависимостей совпадают или совместимы по semantic versioning.

Важный момент: host не включает код remote в свой bundle на этапе сборки. Связь происходит только в рантайме. Именно поэтому изменение в remote не требует пересборки host.

Сам механизм появился в Webpack 5 и стал одним из самых распространённых подходов к микрофронтендам. В Vite встроенной поддержки нет, её добавляют плагины. Основных два:

Дальше в статье сравним их в двух срезах: почему два года назад мы выбрали @originjs и как оба плагина выглядят сегодня, а затем разберём полные конфигурации каждого.

Из чего состоит URL

Здесь и далее в статье встречаются термины origin, hostname, port, path — разберём их на одном примере.

Разбор частей URL: origin как объединение scheme, hostname и port, отдельно от path
Разбор частей URL: origin как объединение scheme, 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 подключают layout через remotes
host-a и host-b подключают layout через remotes

Связь односторонняя: 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. Там схема работы выглядит так:

nginx разводит запросы по путям: /layout/* в remote, /books/* в host
nginx разводит запросы по путям: /layout/* в remote, /books/* в host

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

Как это работает в рантайме:

Загрузка страницы /books от запроса до рендера Layout
Загрузка страницы /books от запроса до рендера Layout

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 лежат в репозитории, их можно поднять локально за несколько минут и потрогать оба плагина. Вопросы и опыт с вашими граблями приносите в комментарии.

Полезные ссылки

Репозитории с примерами

Плагины

Документация