В большой компании фронтенд часто растёт быстрее, чем процессы вокруг него. Сборка репозитория идёт час, релиз уходит раз в две недели, и обновление корзины ждёт в очереди за чужой правкой формы оплаты. Бэкенд давно решил похожую проблему микросервисами, а фронтенд оставался последним монолитом. С выходом webpack 5 в октябре 2020 года у него появился собственный механизм разъединения, Module Federation, и сегодня он поддержан во всех популярных сборщиках. Ниже разобраны механика федерации на уровне сборки и деплоя, типовые конфигурации реальных проектов и грабли, на которые наступает почти каждая команда.
Зачем крупной платформе делить фронтенд на независимые части
Классический монолитный фронтенд диктует организации общий темп. Один репозиторий, один бандл, один конвейер релиза: магазин, личный кабинет и панель управления собирает одна команда или несколько команд, делящих общую ветку. Любое падение сборки останавливает всех, любое изменение требует координации, а частота релизов упирается в самый медленный процесс. Это называют поездом релизов: вагоны прицеплены друг к другу, и состав едет со скоростью последнего вагона.
Архитектура со временем повторяет структуру команд, и фронтенд тут не исключение. Если продукт делят три команды, код естественным образом просит трёх границ. Способы проводить такие границы известны годами: изолированные iframe, встраивание через Web Components, публикация виджетов npm-пакетами. У каждого есть цена. iframe живут в собственных мирах со своей историей навигации и сеансами, компоненты из пакета попадают в хост только после пересборки хоста. Сборочная интеграция связывает релизы намертво, настоящая независимость появляется только когда части приложения встречаются в рантайме, уже собранные и развёрнутые по отдельности.
Отдельная ветка эволюции это нативные import maps, карта модулей, которую браузер понимает сам. Для пары независимых модулей этого хватает, но федерация закрывает больше вопросов сразу: общий доступ к зависимостям, согласование версий, отложенную загрузку и совместимость сборщиков.
Module Federation соединяет отдельные сборки в одно приложение
Идея федерации формулируется одной строкой: несколько отдельных сборок должны складываться в одно целое приложение. Каждая сборка выступает контейнером, который публикует свои модули и может потреблять чужие. Локальные модули обычные, удалённые загружаются в рантайме из чужого контейнера, и это всегда асинхронная операция: без import() потребить удалённый модуль нельзя в принципе.
Сборка, которой нужны внешние части, играет роль хоста, а сборка, публикующая модули, называется remote. Remote собирается со входным файлом remoteEntry.js, это имя стало конвенцией. Контейнер открывает двухшаговый доступ: сначала асинхронная загрузка, затем синхронное исполнение модуля, порядок вычислений не зависит от того, откуда модуль пришёл. Подход так и называют микрофронтендами, хотя собирают им и не только интерфейсы.
У каждой сборки должно быть уникальное имя, по умолчанию оно наследуется из поля name файла package.json, а при совпадении имён сборки становятся неразличимы для общей области, поэтому уникальность закрепляют опцией uniqueName. Экспорт можно описать и объектом с полями import и name, тогда чанк получает стабильное имя, на которое можно ссылаться статически.
Механизм встроен в ядро webpack 5 и не требует плагинов для базового сценария. Вокруг выросла экосистема: пакет @module-federation/enhanced добавляет расширенные возможности, для Rsbuild, Rspack, Next.js и Vite есть собственные обёртки, у Angular сообщество поддерживает пакет @angular-architects/module-federation, а проекты создаются командой npm create module-federation. Сборочным плагинам федерации нужен Node.js не ниже 20 версии.
Конфигурация host и remote умещается в несколько строк
Remote объявляет, что и под каким именем он публикует, host объявляет, где искать чужие контейнеры. Типовой конфиг remote выглядит так:
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
output: {
publicPath: "//cdn.shop.internal/catalog/",
uniqueName: "catalogRemote",
},
plugins: [
new ModuleFederationPlugin({
name: "catalog",
filename: "remoteEntry.js",
exposes: {
"./ProductList": "./src/widgets/ProductList",
"./ProductCard": "./src/widgets/ProductCard",
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
},
}),
],
};
Строка "./ProductList" слева становится адресом для импорта в чужом коде, путь справа указывает на исходный файл. Host подключает результат:
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
catalog: "catalog@//cdn.shop.internal/catalog/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: require("./package.json").dependencies.react },
},
}),
],
};
Формат адреса читается как имя@расположение: до знака собаки идёт имя, под которым контейнер назначается в глобальную область, после идёт путь к файлу remoteEntry.js. Ключ в объекте remotes можно переименовать без согласования с remote, а имя до собаки обязано совпадать с тем, как контейнер назвал сам себя. Потребление в коде хоста сводится к ленивому импорту:
const ProductList = React.lazy(() => import("catalog/ProductList"));
Из-за обязательной асинхронности статического импорта из чужой сборки не существует, ленивая обёртка естественно ложится на Suspense и одноимённые механизмы каркасов. В современных стеках конфигурация переезжает в отдельный файл module-federation.config.ts, который подключается к сборке одной строкой:
// module-federation.config.ts
const config = {
name: "catalog",
filename: "remoteEntry.js",
exposes: {
"./ProductList": "./src/widgets/ProductList",
},
shared: {
react: { singleton: true },
},
};
export default config;
Общие зависимости и цена дублирования библиотек в share scope
Без общих зависимостей федерация теряет половину смысла. Если каждый remote упакует собственную копию react, на странице окажется несколько деревьев одного фреймворка, и хуки с контекстом перестанут видеть друг друга. Поэтому библиотеки объявляют общими: сборки кладут свои копии в общий share scope и берут готовую, а не таскают приватную. Share scope это обычный объект, где под ключом модуля лежат предложенные версиями копии, если версий несколько, побеждает старшая семантическая.
Для библиотек с внутренним глобальным состоянием, react и react-dom, допускается только один экземпляр, подсказка singleton именно это закрепляет. requiredVersion задаёт допустимый диапазон, по умолчанию он берётся из package.json. Строгая проверка strictVersion превращает несовпадение из предупреждения в ошибку исполнения, а подсказка eager разрешает использовать общую библиотеку в стартовом чанке. Бывает и обратная ситуация: remote сознательно не тащит библиотеку вообще, а рассчитывает на чужую копию. Для этого подсказке import присваивают значение false, а strictVersion превращает несовпадение в ошибку, и страница падает при релизе, а не ведёт себя непредсказуемо в продакшене. Договор о версиях между командами выглядит так:
shared: {
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
strictVersion: true,
},
"design-system": {
singleton: true,
requiredVersion: deps["design-system"],
strictVersion: true,
},
}
Независимый деплой команд от сборки до отката версии
Деплой федеративного приложения это не один релиз, а несколько параллельных. Remote каждой команды публикуется в собственный каталог, чанки получают хешированные имена, и хост находит их по адресу remoteEntry.js. Хешированные файлы меняются только при изменении содержимого, поэтому кэшируются агрессивно и бессрочно, а вот remoteEntry.js обязан обновляться сразу, для него придумывают стабильный адрес или манифест.
Современные инструменты федерации генерируют mf-manifest.json, где перечислены опубликованные модули, адрес remote entry, нужные ассеты, доступные общие зависимости и файлы типов. Рантайм хоста читает манифест, строит по нему снимок и только потом грузит код. Внешний сервис доставки может разрешить манифест заранее и отдать хосту готовый снимок, убирая лишний запрос из пути пользователя. Манифест помогает и в диагностике: отличить недоступный манифест от неработающего remote entry или отсутствующего экспорта.
Порядок релиза тоже договорной вопрос: сначала публикуется новая версия remote и только потом хост переключает манифест, иначе пользователь получает запрос к ещё не существующему файлу. Команда фреймворка развивает облачный сервис публикации, который размещает сборки в глобальной сети доставки и хранит версии для автоматического отката.
Типовой цикл независимого релиза remote укладывается в четыре шага:
- сборка с хешированными именами чанков и публикация в каталог с номером версии;
- включение долгого кэширования для статических файлов нового каталога;
- переключение стабильного адреса remoteEntry.js или манифеста на новую версию;
- контроль метрик и откат на предыдущий каталог при регрессии.
Локацию remote можно разрешать и в рантайме, для этого remotes принимает промис:
remotes: {
catalog: `promise new Promise((resolve, reject) => {
const src = "//cdn.shop.internal/" + window.catalogRelease + "/remoteEntry.js";
const script = document.createElement("script");
script.onload = () => resolve({
get: (request) => window.catalog.get(request),
init: (shareScope) => {
try { window.catalog.init(shareScope); }
catch (e) { console.warn("init failed", e); }
},
});
script.onerror = reject;
script.src = src;
document.head.appendChild(script);
})`,
}
Хост вычисляет адрес по конфигурации или по ответу собственного сервиса и грузит контейнер динамически, не зашивая путь в сборку. Так рабочая среда, эксперименты и откат решаются сменой одной переменной, без пересборки хоста.
Типичные ошибки федерации и способы их лечения
Первая классика звучит в консоли как "Uncaught Error: Shared module is not available for eager consumption". Стартовый чанк хоста пытается исполнить общую библиотеку до того, как асинхронная граница открыла доступ к share scope. Лечат двумя путями: пометить библиотеку eager у хоста либо сохранить асинхронную границу, публиковать экспорт с ключом "./Button", а не "Button".
Вторая ошибка: "Container initialization failed as it has already been initialized with a different share scope". Контейнер инициализируют дважды с разными областями, обычно после ручных вызовов init в обход рантайма. Третья ловушка тихая: две сборки с одинаковым именем из package.json неразличимы для общей области, побеждает случайная копия, уникальность закрепляется опцией uniqueName. И самая коварная проблема живёт снаружи конфигурации: рассинхронизация мажорных версий общей библиотеки между командами, когда singleton молча подставляет старшую версию туда, где её не ждали.
Договорённости между командами важнее любого плагина
Технология даёт границу, но не даёт правил. В крупной организации независимость деплоя работает только поверх договорённостей. Дизайн-система живёт общим пакетом и объявляется singleton с requiredVersion, чтобы визуальный язык не расползался. Стили изолируются: CSS-модули, префиксы по команде или теневой DOM не дают корзине сломать шапку. Маршрутизация остаётся за хостом, remote получают события и параметры, а не права на адресную строку. Контракты типов уходят в манифест вместе с кодом, поэтому правки интерфейсов видны соседям ещё до релиза. Сквозные e2e-тесты прогоняют собранное приложение целиком, а не по кускам, потому что интеграция в рантайме и ломается в рантайме.
За экраном помогает простая гигиена: панель со списком версий remote в подвале страницы ускоряет разбор инцидентов, а экспериментальные флаги позволяют выкатывать свою часть на малую долю трафика.
Федерация не отменяет архитектурные обязанности, она меняет точку их применения. Команда каталога ведёт свой репозиторий, свой конвейер и свой темп релизов, а договор с соседями сводится к паре строк конфигурации и версии дизайн-системы. Именно это и покупают крупные организации: не новую технологию ради технологии, а право каждой команде поставлять свою часть в день готовности, не дожидаясь общего поезда.