В архитектуре микрофронтендов на базе Webpack и плагина Module
Federation Plugin ключевой проблемой становится управление удалёнными
контейнерами (remote) в рантайме. Статическая конфигурация
remotes в большинстве случаев быстро упирается в
ограничения: фиксированные URL, невозможность переключения окружений без
пересборки, сложность A/B-развёртывания и динамического подключения
версий.
Обычная конфигурация Module Federation предполагает жёстко заданный remote:
remotes: {
app2: "app2@https://cdn.example.com/app2/remoteEntry.js"
}
Такой подход формирует статическую связку host → remote, которая закрепляется на этапе сборки. Любое изменение адреса требует новой сборки host-приложения.
Динамические Remote URL устраняют эту зависимость, перенося
разрешение адреса remoteEntry.js в runtime. Вместо
фиксированной строки используется функция, промис или внешний конфиг,
который может изменяться без пересборки основного приложения.
Webpack позволяет подменять значение remotes через
функцию:
remotes: {
app2: "promise new Promise(resolve => { ... })"
}
Однако более корректный подход — использовать асинхронную загрузку контейнера через динамический URL.
Основная идея заключается в том, что remote становится не строкой, а функцией, возвращающей контейнер:
const loadRemote = (url, scope) => {
return async () => {
await __webpack_init_sharing__("default");
const container = window[scope];
if (!container) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onl oad = resolve;
script.oner ror = reject;
document.head.appendChild(script);
});
}
await container.init(__webpack_share_scopes__.default);
return container;
};
};
Ключевой элемент динамического подхода — генерация URL во время выполнения приложения:
const getRemoteUrl = (name) => {
const base = window.__RUNTIME_CONFIG__.CDN_BASE;
const version = window.__RUNTIME_CONFIG__.VERSION;
return `${base}/${name}/${version}/remoteEntry.js`;
};
Такой слой абстракции позволяет:
В конфигурации Module Federation:
remotes: {
app2: `promise new Promise((resolve) => {
const url = getRemoteUrl("app2");
const script = document.createElement("script");
script.src = url;
script.onl oad = () => {
resolve(window.app2);
};
document.head.appendChild(script);
})`
}
Здесь происходит ключевое изменение: remote становится результатом
выполнения promise, который загружает внешний контейнер и регистрирует
его в глобальном объекте window.
Более масштабируемая архитектура строится вокруг централизованного загрузчика:
const remoteRegistry = {
app2: {
scope: "app2",
url: () => getRemoteUrl("app2"),
},
app3: {
scope: "app3",
url: () => getRemoteUrl("app3"),
}
};
export const loadRemoteModule = async (name, module) => {
const remote = remoteRegistry[name];
if (!remote) {
throw new Error(`Remote ${name} not found`);
}
await loadScript(remote.url(), remote.scope);
const container = window[remote.scope];
await __webpack_init_sharing__("default");
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(module);
return factory();
};
Отдельного внимания требует управление загрузкой
remoteEntry.js:
const loadScript = (src, scope) => {
return new Promise((resolve, reject) => {
if (window[scope]) {
resolve();
return;
}
const existing = document.querySelector(`script[data-scope="${scope}"]`);
if (existing) {
existing.addEventListener("load", resolve);
existing.addEventListener("error", reject);
return;
}
const script = document.createElement("script");
script.src = src;
script.dataset.scope = scope;
script.onl oad = resolve;
script.oner ror = reject;
document.head.appendChild(script);
});
};
Это предотвращает повторную загрузку одного и того же remote и снижает риск конфликтов состояния.
Часто динамические remote URL привязываются к конфигурации окружения:
window.__RUNTIME_CONFIG__ = {
ENV: "production",
CDN_BASE: "https://cdn.production.example.com",
REMOTES: {
app2: "v3",
app3: "v5"
}
};
На основе этого формируется единая точка принятия решений:
const resolveRemoteVersion = (name) => {
return window.__RUNTIME_CONFIG__.REMOTES[name] || "latest";
};
Такой подход позволяет:
Более зрелая модель использует manifest.json,
загружаемый при старте приложения:
{
"app2": {
"url": "https://cdn.example.com/app2/remoteEntry.js",
"scope": "app2",
"version": "2.1.0"
}
}
Загрузка manifest:
const fetchManifest = async () => {
const res = await fetch("/mf-manifest.json");
return res.json();
};
Далее remote registry строится динамически:
const buildRegistry = (manifest) => {
return Object.entries(manifest).reduce((acc, [key, value]) => {
acc[key] = {
scope: value.scope,
url: () => value.url
};
return acc;
}, {});
};
При динамической загрузке важно корректно инициализировать общий scope зависимостей:
await __webpack_init_sharing__("default");
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
Ошибки на этом этапе приводят к дублированию React, lodash и других shared-зависимостей, что критично для runtime-стабильности.
Динамические URL часто включают версию как часть пути:
/app2/2.3.1/remoteEntry.js
Либо через query-параметр:
remoteEntry.js?v=2.3.1
Это позволяет управлять:
При динамическом подключении remote важно учитывать отказоустойчивость:
const loadWithFallback = async (urls) => {
for (const url of urls) {
try {
return await loadRemote(url);
} catch (e) {
continue;
}
}
throw new Error("All remote sources failed");
};
Такой механизм позволяет переключаться между CDN или регионами доставки без вмешательства в основной билд.
Динамические URL увеличивают поверхность атаки, поскольку remote-источник становится конфигурируемым в runtime. В таких системах применяются ограничения:
const isAllowed = (url) => {
return url.startsWith("https://cdn.example.com/");
};
В динамических remote URL формируется переход от compile-time интеграции к runtime-композиции. Host-приложение перестаёт быть жёстко связанным с remote-сборками и начинает функционировать как orchestrator, который:
Такой подход особенно важен в системах, где количество микрофронтендов растёт, а частота релизов различается между командами разработки.