Асинхронная загрузка federated-модулей

Асинхронная модель загрузки в Module Federation строится вокруг идеи отложенного получения удалённых модулей через динамические запросы к remote-контейнерам. В контексте Webpack это означает, что каждый remote может быть представлен как отдельный runtime-объект, доступный только в момент выполнения, а не на этапе сборки.

Ключевое отличие от статического связывания заключается в том, что remote-модуль не существует в бандле хоста до момента обращения к нему. Его код загружается как отдельный JavaScript-чанк, после чего выполняется и регистрируется в глобальном контейнере.

Базовая модель асинхронной федерации

Асинхронная федерация опирается на три базовых этапа:

  1. Загрузка remote-скрипта
  2. Инициализация контейнера
  3. Получение экспортируемого модуля

Эти этапы в Webpack Module Federation реализуются через runtime API контейнера:

// упрощённая модель remote-контейнера
const container = window.remoteApp;

await container.init(__webpack_share_scopes__.default);
const factory = await container.get("./Button");
const Module = factory();

Каждый шаг является асинхронным, поскольку включает сетевой запрос или ленивую инициализацию зависимостей.

Promise-based remotes и динамическая инициализация

Современные конфигурации используют promise-based remotes, где сам remote определяется как функция, возвращающая Promise:

const remote = () => import("remoteApp/Button");

На уровне Webpack это трансформируется в загрузку remoteEntry.js и последующий доступ к контейнеру.

Более гибкий вариант через фабрику:

const loadRemote = async (scope, module) => {
  await __webpack_init_sharing__("default");
  const container = window[scope];

  await container.init(__webpack_share_scopes__.default);
  const factory = await container.get(module);

  return factory();
};

Такой подход позволяет централизовать управление загрузкой и добавлять дополнительные слои логики: кеширование, retry, логирование.

Использование import() и контейнеров

Динамический import() является базовым механизмом ленивой загрузки, поверх которого строится federated runtime:

const Button = await import("remoteApp/Button");

Webpack заменяет этот вызов на обращение к remote-контейнеру, который сначала загружается как отдельный chunk:

import(/* webpackIgnore: true */ "https://cdn.example.com/remoteEntry.js")

После загрузки скрипта происходит регистрация глобального контейнера:

window.remoteApp = {
  get: async (module) => { /* ... */ },
  init: async (shareScope) => { /* ... */ }
};

Жизненный цикл remote: load → init → get

Асинхронная загрузка всегда проходит через фиксированный жизненный цикл:

1. Load

Загрузка remoteEntry.js через script tag или dynamic import.

2. Init

Инициализация share scope, синхронизация зависимостей между хостом и remote:

await container.init(__webpack_share_scopes__.default);

3. Get

Получение фабрики модуля:

const factory = await container.get("./module");
const Module = factory();

Важно, что factory() выполняется уже локально и синхронно, после завершения асинхронных шагов.

Управление загрузкой и состояниями

Асинхронная природа federated-модулей требует управления состояниями загрузки. Типичная модель включает:

  • idle (не запрошен)
  • loading (идёт загрузка remote)
  • ready (модуль доступен)
  • error (ошибка загрузки)

Пример обёртки:

const registry = new Map();

export const loadFederatedModule = async (key, loader) => {
  if (registry.has(key)) {
    return registry.get(key);
  }

  const promise = loader()
    .then((mod) => {
      registry.set(key, mod);
      return mod;
    });

  registry.set(key, promise);

  return promise;
};

Такая модель предотвращает повторные запросы и обеспечивает дедупликацию загрузки.

Обработка ошибок и повторные попытки

Асинхронная загрузка remote-контейнеров подвержена сетевым сбоям, недоступности CDN и конфликтам версий.

Распространённый подход — retry-стратегия:

const loadWithRetry = async (fn, retries = 3) => {
  let lastError;

  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e;
    }
  }

  throw lastError;
};

Дополнительно применяются fallback-remotes:

const remotes = [
  "https://cdn1.example.com/remoteEntry.js",
  "https://cdn2.example.com/remoteEntry.js"
];

Загрузка происходит по цепочке до первого успешного результата.

Предзагрузка и приоритизация чанков

Асинхронная федерация выигрывает от стратегий предзагрузки:

  • prefetch для второстепенных модулей
  • preload для критических компонентов
  • idle-time загрузка

Webpack поддерживает hints:

import(
  /* webpackPrefetch: true */
  "remoteApp/LazyComponent"
);

В runtime это превращается в добавление <link rel="prefetch">.

Также применяется ручная предзагрузка:

const prefetchRemote = (url) => {
  const link = document.createElement("link");
  link.rel = "preload";
  link.as = "script";
  link.href = url;
  document.head.appendChild(link);
};

Кэширование и повторное использование контейнеров

После загрузки remote-контейнер сохраняется в глобальной области:

window.remoteApp

Однако на уровне приложения требуется более строгий контроль:

const containerCache = new Map();

export const getContainer = async (scope, url) => {
  if (containerCache.has(scope)) {
    return containerCache.get(scope);
  }

  await import(/* webpackIgnore: true */ url);

  const container = window[scope];
  containerCache.set(scope, container);

  return container;
};

Такой подход предотвращает повторную загрузку remoteEntry.js.

Взаимодействие с shared scope асинхронно

Shared scope инициализируется один раз на приложение, но его синхронизация происходит асинхронно:

await __webpack_init_sharing__("default");
await container.init(__webpack_share_scopes__.default);

Проблема возникает при конкурирующих загрузках нескольких remotes. В этом случае важно гарантировать единичную инициализацию:

let shareInitPromise;

export const initShare = () => {
  if (!shareInitPromise) {
    shareInitPromise = __webpack_init_sharing__("default");
  }
  return shareInitPromise;
};

Практические паттерны загрузки

Loader-обёртка

export const createRemoteLoader = (scope, url) => {
  return async (module) => {
    await import(/* webpackIgnore: true */ url);

    const container = window[scope];
    await container.init(__webpack_share_scopes__.default);

    const factory = await container.get(module);
    return factory();
  };
};

Registry-модель

const registry = new Map();

export const defineRemote = (name, loader) => {
  registry.set(name, loader);
};

export const useRemote = (name) => {
  return registry.get(name)?.();
};

Lazy proxy

const createLazy = (loader) => {
  let instance;

  return new Proxy({}, {
    get: async (_, prop) => {
      if (!instance) {
        instance = await loader();
      }
      return instance[prop];
    }
  });
};

Оптимизация производительности

Асинхронная загрузка требует минимизации задержек:

  • объединение remote-запросов
  • сокращение числа HTTP round-trips
  • использование HTTP/2 multiplexing
  • уменьшение размера remoteEntry.js
  • агрессивное кэширование CDN

Также важна стратегия cold start:

const warmup = async () => {
  await Promise.all([
    loadRemote("app1", "/remoteEntry.js"),
    loadRemote("app2", "/remoteEntry.js")
  ]);
};

Типичные проблемы асинхронной федерации

Конфликт версий shared dependencies

При несовпадении версий пакетов возможна деградация runtime или fallback на локальные копии.

Race conditions при init

Одновременная инициализация контейнера приводит к неопределённому поведению без защиты через singleton-паттерны.

Частичная недоступность CDN

Необходимы fallback-цепочки и деградация функциональности.

Повторная загрузка remoteEntry

Возникает при отсутствии централизованного cache layer.

Несогласованность share scope

При неправильном порядке init возможны ошибки резолвинга зависимостей внутри remote.