exposes: публикация модулей

В экосистеме Webpack одним из ключевых инструментов реализации микрофронтендов является Module Federation. В этой архитектуре отдельные приложения могут выступать как host (контейнер) и remote (удалённый модуль), обмениваясь кодом во время выполнения.

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

Роль exposes в архитектуре Module Federation

exposes определяет набор модулей, которые будут экспортированы из текущего бандла. Эти модули становятся доступными по сетевому имени remote-приложения.

Фактически формируется словарь:

  • ключ — публичное имя модуля
  • значение — путь к локальному модулю внутри проекта

Этот слой абстракции позволяет:

  • скрывать внутреннюю структуру проекта
  • контролировать публичный API микрофронтенда
  • изолировать рефакторинг без влияния на потребителей
  • формировать стабильные точки интеграции

Базовая конфигурация exposes

В конфигурации Module Federation параметр exposes задаётся внутри ModuleFederationPlugin.

new ModuleFederationPlugin({
  name: "app_remote",
  filename: "remoteEntry.js",
  exposes: {
    "./Button": "./src/ui/Button",
    "./utils": "./src/shared/utils"
  }
});

Каждая запись означает:

  • ./Button — публичный путь, по которому модуль будет запрошен
  • ./src/ui/Button — реальный файл внутри remote-приложения

При этом host-приложение обращается к модулю через строку:

import("app_remote/Button");

Формирование публичного API

exposes фактически превращается в контракт между приложениями. В отличие от обычных npm-пакетов, публикация происходит во время выполнения, а не на этапе сборки.

Основные характеристики такого API:

  • динамическая загрузка
  • отсутствие необходимости публикации в registry
  • возможность обновления без пересборки host-приложения
  • разделение версий на уровне runtime

Структура экспорта модулей

Простые модули

Наиболее распространённый вариант — экспорт компонентов UI или утилит.

exposes: {
  "./Header": "./src/components/Header",
  "./formatDate": "./src/utils/formatDate"
}

Группировка по доменам

При росте приложения применяется доменная структура:

exposes: {
  "./auth/LoginForm": "./src/auth/LoginForm",
  "./auth/useAuth": "./src/auth/useAuth",
  "./cart/CartWidget": "./src/cart/CartWidget"
}

Такой подход формирует логическую сегментацию API remote-приложения.

Экспорт React/Vue компонентов

Часто Module Federation используется для UI-микрофронтендов.

Пример экспорта компонента:

// src/components/ProfileCard.jsx
export default function ProfileCard({ user }) {
  return (
    <div>
      {user.name}
    </div>
  );
}
exposes: {
  "./ProfileCard": "./src/components/ProfileCard"
}

Импорт на стороне host:

import ProfileCard from "app_remote/ProfileCard";

Named exports и ограничения

Module Federation работает с ES Modules, но поведение зависит от сборки.

Если используется named export:

// utils.js
export const formatDate = (date) => {
  return date.toISOString();
};

Экспорт через exposes:

exposes: {
  "./utils": "./src/utils"
}

Использование:

import("app_remote/utils").then((module) => {
  module.formatDate(new Date());
});

Важный момент: весь модуль экспортируется как единый namespace-объект.

Lazy loading как базовое поведение

Любой модуль, объявленный через exposes, загружается лениво:

  • код не попадает в host bundle
  • загрузка происходит по требованию
  • создаётся отдельный chunk на стороне remote

Пример:

const RemoteButton = React.lazy(() =>
  import("app_remote/Button")
);

Это позволяет снижать initial bundle size host-приложения.

Runtime-механизм публикации

При сборке remote-приложения создаётся файл:

remoteEntry.js

Он содержит:

  • карту exposed модулей
  • runtime-логику загрузки
  • механизм резолва зависимостей

Host-приложение:

  1. загружает remoteEntry.js
  2. регистрирует контейнер
  3. получает доступ к exposed модулям
  4. выполняет dynamic import

Переименование и стабильность контрактов

Публичное имя модуля ("./Button") не обязано совпадать с файловой структурой.

Практика:

exposes: {
  "./PrimaryButton": "./src/components/ui/buttons/Primary"
}

Это позволяет:

  • скрывать рефакторинг директорий
  • сохранять стабильный API
  • постепенно мигрировать структуру проекта

Совместимость версий

При использовании exposes возникает вопрос версионности:

  • разные remote могут экспортировать одинаковые пути
  • host не знает внутреннюю версию модуля
  • конфликт решается на уровне name + url remote

Пример:

remotes: {
  app_remote: "app_remote@https://cdn.site.com/remoteEntry.js"
}

Обновление remote не требует пересборки host, если контракт exposes сохранён.

Общие паттерны использования

1. UI-библиотека как remote

Remote содержит только дизайн-систему:

exposes: {
  "./Button": "./src/ui/Button",
  "./Modal": "./src/ui/Modal"
}

2. Бизнес-модули

exposes: {
  "./checkout": "./src/features/checkout",
  "./billing": "./src/features/billing"
}

3. Гибридный подход

UI + логика в одном remote:

exposes: {
  "./components/Cart": "./src/components/Cart",
  "./hooks/useCart": "./src/hooks/useCart"
}

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

1. Слишком мелкая гранулярность

Избыточное дробление:

exposes: {
  "./a": "./src/a",
  "./b": "./src/b",
  "./c": "./src/c"
}

Проблемы:

  • усложнение контрактов
  • рост сетевых запросов
  • потеря целостности модуля

2. Экспорт внутренних деталей

Экспонирование внутренних утилит без стабильного API приводит к:

  • жёсткой связности
  • невозможности рефакторинга
  • нарушению инкапсуляции

3. Отсутствие доменной структуры

Плоская структура exposes затрудняет масштабирование.

Безопасность и контроль экспорта

Exposes становится публичной поверхностью приложения, поэтому:

  • нельзя экспортировать чувствительные модули (auth internals, secrets)
  • необходимо ограничивать доступ к инфраструктурным слоям
  • рекомендуется экспортировать только UI и сервисные абстракции

Связь exposes с shared зависимостями

Хотя exposes отвечает за публикацию модулей, он тесно связан с shared:

  • exposes публикует код
  • shared управляет зависимостями (React, lodash и т.д.)

Несогласованность этих частей приводит к:

  • дублированию библиотек
  • конфликтам версий
  • увеличению bundle size

Поведение при изменении exposes

Любое изменение структуры:

  • требует пересборки remote
  • может сломать host при изменении имени ключа
  • не требует изменения host при сохранении контракта

Критическим является именно публичный ключ, а не путь к файлу.

Динамическая эволюция API

В зрелых системах exposes рассматривается как версионируемый API:

  • добавление новых ключей — безопасная операция
  • удаление ключей — breaking change
  • переименование — эквивалент удаления + добавления

Подход аналогичен REST API контрактам, но на уровне JavaScript runtime.

Использование TypeScript с exposes

Для типизации используется декларация модулей:

declare module "app_remote/Button" {
  const Button: React.ComponentType<any>;
  export default Button;
}

Это позволяет:

  • получить автодополнение
  • избежать ошибок импорта
  • зафиксировать контракт remote-модуля

Итоговая модель поведения exposes

  • формирует публичный API remote-приложения
  • работает на уровне runtime загрузки
  • не требует публикации в npm
  • полностью зависит от Module Federation runtime
  • определяет границы микрофронтенд-архитектуры