Singleton-зависимости и версионирование

В модульной экосистеме JavaScript под singleton-зависимостью понимается экземпляр модуля, который должен существовать в приложении в единственном числе во время выполнения. В условиях классического bundling-подхода это не гарантируется автоматически: один и тот же пакет может быть включён в сборку несколько раз, если он встречается в разных графах зависимостей или если разные части системы используют разные версии.

В Webpack это становится особенно критично при переходе к микрофронтендам и динамической загрузке чанков, где границы сборок перестают совпадать с границами рантайма.

Ключевая проблема singleton-зависимостей заключается в том, что JavaScript-модули в разных бандлах перестают быть идентичными ссылочно. Даже если код одинаковый, разные инстансы модуля приводят к разным состояниям, разным контекстам и несовместимости внутренних кэшей.


Причины появления дубликатов модулей

Дублирование зависимостей в Webpack возникает не случайно, а как следствие особенностей резолва модулей:

  • различие версий пакета в поддеревьях зависимостей
  • отсутствие жёсткой дедупликации в node_modules при установке
  • использование разных package manager стратегий (npm, yarn, pnpm)
  • неодинаковые пути резолва (alias, symlink, workspace)
  • отдельные сборки микрофронтендов, каждая со своим node_modules
  • динамическая загрузка remote-модулей

Даже в монолитной сборке возможно появление двух экземпляров одной библиотеки, если Webpack не может доказать их идентичность на этапе dependency graph optimization.

Особенно критичны библиотеки, содержащие глобальное состояние:

  • React и React DOM
  • Redux store
  • Event emitter системы
  • UI библиотеки с контекстом
  • библиотеки интернационализации

Поведение Webpack при дублировании

Webpack 5 выполняет анализ зависимостей и частичную дедупликацию, однако не может гарантировать singleton-семантику на уровне рантайма.

При сборке происходит следующее:

  1. Формируется dependency graph
  2. Каждая точка входа анализируется независимо
  3. Общие модули выносятся в shared chunks (если включён optimization.splitChunks)
  4. Однако разные сборки (особенно Module Federation) могут содержать собственные копии одного и того же пакета

В результате:

  • один модуль существует в нескольких бандлах
  • каждый бандл имеет собственный module cache
  • состояния не синхронизируются

Singleton как контракт выполнения

Singleton-зависимость в Webpack не является автоматически обеспечиваемым свойством. Это контракт между разработчиками разных частей системы.

Он предполагает:

  • наличие одной копии модуля в runtime
  • идентичность версии или совместимость версий
  • общий module cache в пределах одного share scope
  • согласованное разрешение зависимостей

В противном случае даже простое сравнение instanceof может начать возвращать ложные результаты, поскольку классы будут загружены из разных контекстов.


Module Federation и механизм shared singleton

В Webpack 5 проблема singleton-зависимостей решается через Module Federation. Основной механизм — shared dependencies.

Конфигурация shared модуля позволяет определить, что конкретная библиотека должна быть разделена между host и remote.

Пример:

new ModuleFederationPlugin({
  name: "app",
  remotes: {
    remoteApp: "remoteApp@http://localhost:3001/remoteEntry.js"
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: "^18.0.0"
    },
    "react-dom": {
      singleton: true,
      requiredVersion: "^18.0.0"
    }
  }
});

Здесь ключевое значение имеет параметр singleton: true, который указывает, что Webpack должен обеспечить единственный экземпляр зависимости в рамках share scope.


Механика share scope

Share scope представляет собой рантайм-реестр модулей, доступных для совместного использования между контейнерами.

Процесс выглядит следующим образом:

  1. Host приложение и remote регистрируют свои shared зависимости
  2. При загрузке remote выполняется проверка наличия модуля в share scope
  3. Если модуль уже существует — используется существующий экземпляр
  4. Если нет — загружается новый и помещается в scope

Таким образом, share scope становится механизмом координации singleton-экземпляров между независимыми сборками.


Версионирование singleton-зависимостей

Версионирование играет критическую роль, поскольку singleton не означает «любая версия подходит». Несовместимые версии могут нарушить контракт API.

В Webpack используются несколько стратегий контроля версий:

requiredVersion

Параметр requiredVersion задаёт допустимый диапазон версии:

shared: {
  lodash: {
    singleton: true,
    requiredVersion: "^4.17.0"
  }
}

Webpack проверяет соответствие версии и принимает решение:

  • использовать уже загруженную версию
  • или отклонить её и загрузить альтернативную

strictVersion

Параметр strictVersion усиливает контроль:

shared: {
  react: {
    singleton: true,
    strictVersion: true,
    requiredVersion: "18.2.0"
  }
}

При strictVersion: true несовпадение версии приводит к ошибке загрузки или отказу от использования shared модуля.


semantic versioning и риски

SemVer в singleton-контексте работает не как рекомендация, а как механизм совместимости runtime.

Основные риски:

  • patch-версии могут менять поведение без изменения API
  • minor-версии могут добавлять побочные эффекты
  • разные microfrontends могут зависеть от разных интервалов версий

Особенно опасны ситуации, когда:

  • host использует React 18.2.0
  • remote использует React 18.1.0
  • обе версии объявлены как singleton

Даже небольшие различия приводят к нарушению hook-цепочек и контекста.


Конфликты singleton-зависимостей

Конфликты возникают при невозможности согласовать версию модуля между несколькими участниками share scope.

Типовые сценарии:

Несовместимые диапазоны версий

Если один remote требует ^17.0.0, а другой ^18.0.0, Webpack не может выбрать единую версию.

Несогласованный singleton

Если один модуль объявляет зависимость как singleton, а другой нет, возникает дублирование экземпляров.

Разные сборочные контексты

Monorepo и независимые сборки могут по-разному интерпретировать зависимости, особенно при hoisting.


Влияние npm и node_modules структуры

Физическая структура зависимостей в node_modules напрямую влияет на Webpack resolution.

Особенности:

  • npm v7+ активно дедуплицирует зависимости
  • yarn workspaces поднимает зависимости на корень
  • pnpm использует жесткие ссылки и изоляцию

Эти различия приводят к тому, что Webpack может видеть один и тот же пакет как:

  • единый модуль
  • несколько разных путей
  • или разные физические инстансы

В результате singleton-логика может работать нестабильно без явного контроля через Module Federation.


Практика стабилизации singleton-зависимостей

Для предсказуемого поведения применяются следующие подходы:

Жёсткое выравнивание версий

Все микрофронтенды используют одинаковые версии критических библиотек.

Централизованный share scope

Host управляет основными singleton-зависимостями, remote только подключаются.

Явное объявление singleton

Каждая критическая зависимость помечается singleton: true, исключая случайное дублирование.

Контроль peerDependencies

Peer dependencies фиксируют ожидание внешнего предоставления зависимости:

{
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

Это переносит ответственность за версию на верхний уровень системы.


Ранние ошибки и их проявления

Ошибки singleton-зависимостей часто проявляются не сразу, а на уровне runtime:

  • нарушение React hooks rules
  • потеря контекста Provider
  • невозможность сравнения классов через instanceof
  • дублирование event listeners
  • расхождение состояния глобальных сторей

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


Роль Webpack resolver в дедупликации

Webpack использует resolution strategy, включающую:

  • resolve.modules
  • resolve.alias
  • resolve.symlinks
  • resolve.cacheWithContext

Однако ни один из этих механизмов не гарантирует singleton без участия Module Federation. Resolver работает на этапе сборки, тогда как singleton требует контроля на уровне runtime.


Runtime-аспекты singleton-модулей

После загрузки бандла Webpack формирует module cache. Singleton-семантика должна быть согласована между несколькими независимыми cache.

Module Federation решает это через общий share scope, который действует как внешний registry поверх локальных кешей.

Без него:

  • каждый бандл живёт в изоляции
  • кэш не пересекается
  • состояние не синхронизируется

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

Singleton-зависимость в Webpack формируется как комбинация трёх слоёв:

  • dependency graph на этапе сборки
  • resolution strategy на уровне bundler
  • share scope на уровне runtime

Версионирование при этом выступает как фильтр совместимости, определяющий возможность совместного использования одного экземпляра модуля.

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