Circular dependencies и circular-dependency-plugin

Циклические зависимости в модульной системе возникают тогда, когда два или более модуля прямо или косвенно зависят друг от друга. В контексте сборщика Webpack это одна из наиболее сложных категорий проблем, поскольку она затрагивает порядок инициализации модулей, кеширование экспорта и поведение runtime-обвязки.

В экосистеме ES Modules и CommonJS цикл появляется, когда граф зависимостей перестаёт быть ацикличным:

  • модуль A импортирует модуль B
  • модуль B импортирует модуль C
  • модуль C импортирует модуль A

На уровне исполнения это приводит к частично инициализированным модулям. В случае ES Modules экспорт существует, но его значения могут быть не до конца заполнены на момент обращения. В CommonJS ситуация выражена через module.exports, который может возвращать незаполненный объект или undefined свойства.

Webpack строит внутренний граф модулей, где каждый файл — вершина, а импорт — ребро. При наличии цикла граф перестаёт быть DAG (Directed Acyclic Graph), и порядок выполнения становится неоднозначным.

Как Webpack обрабатывает циклы

Внутри Webpack используется ленивое выполнение модулей через функцию-обёртку. Каждый модуль кэшируется после первого вызова:

  • модуль помещается в installedModules
  • при повторном require или import возвращается кеш
  • код модуля выполняется один раз

При циклической зависимости происходит следующее:

  1. модуль A запускается
  2. в процессе выполнения требуется модуль B
  3. модуль B запускается
  4. B требует A
  5. A уже в кеше, но ещё не завершён → возвращается частичный экспорт

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

Типовые сценарии возникновения циклов

Barrel-файлы (index.ts / index.js)

Самый частый источник циклов — агрегирующие файлы:

// index.js
export { a } from './a';
export { b } from './b';
// a.js
import { b } from './index';

Здесь создаётся косвенный цикл через re-export. Особенно часто это проявляется в больших UI-библиотеках и feature-based архитектурах.

Двусторонние сервисы

// userService.js
import { logAction } from './logger';

export function getUser() {
  logAction('getUser');
}
// logger.js
import { getUser } from './userService';

export function logAction(action) {
  if (action === 'init') {
    getUser();
  }
}

Логирование и бизнес-логика часто образуют скрытые циклы.

React/State менеджеры

В приложениях на React циклы часто возникают между:

  • компонентами
  • хуками
  • store (Redux, Zustand)
  • утилитами

Особенно опасны циклы через shared index-файлы и глобальные реэкспорты.

Симптомы циклических зависимостей

  • undefined is not a function
  • частично инициализированные объекты
  • поведение, зависящее от порядка импортов
  • ошибки, появляющиеся только в production сборке
  • “магическое” исчезновение данных в рантайме

В сборке Webpack такие ошибки часто трудно локализуются, потому что сам бандл не падает — ломается логика инициализации.

Инструменты обнаружения

Для анализа циклов используется плагин circular-dependency-plugin. Он интегрируется в процесс сборки и отслеживает появление циклических связей в графе модулей.

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

const CircularDependencyPlugin = require('circular-dependency-plugin');

module.exports = {
  plugins: [
    new CircularDependencyPlugin({
      exclude: /node_modules/,
      failOnError: false,
      allowAsyncCycles: false,
      cwd: process.cwd(),
    }),
  ],
};

Поведение параметров плагина

  • exclude — исключение внешних библиотек, где циклы часто допустимы или неизбежны
  • failOnError — остановка сборки при обнаружении цикла
  • allowAsyncCycles — игнорирование циклов, возникающих в async import
  • cwd — базовая директория для вывода путей

При включении строгого режима (failOnError: true) сборка превращается в механизм контроля архитектуры.

Интерпретация результатов

Плагин выводит цепочку:

Circular dependency detected:
src/a.js -> src/b.js -> src/c.js -> src/a.js

Ключевая задача — определить не сам цикл, а причину его появления. Обычно это:

  • неправильная архитектура слоёв
  • нарушение направленности зависимостей
  • чрезмерное использование barrel-файлов
  • скрытая зависимость через shared utils

Стратегии устранения циклов

Инверсия зависимостей

Вместо прямого импорта используется передача зависимостей:

// logger.js
export function createLogger(getUser) {
  return {
    log() {
      getUser();
    }
  };
}

Это разрывает прямую связь модулей.

Выделение доменного слоя

Если два модуля зависят друг от друга, часто это сигнал, что они относятся к одной доменной области и требуют выделения общего слоя.

Удаление barrel-циклов

Barrel-файлы часто создают ложное ощущение чистоты API, но внутри скрывают циклы. Решение — прямые импорты:

import { a } from './a';
import { b } from './b';

вместо реэкспорта через index.

Dynamic imports

Ленивая загрузка может разорвать цикл:

export async function loadUser() {
  const { getUser } = await import('./userService');
  return getUser();
}

Webpack корректно разделяет такие зависимости на чанки, уменьшая вероятность раннего исполнения цикла.

Циклы в TypeScript и сборке

В проектах с TypeScript проблема усиливается из-за:

  • генерации index-файлов
  • автосборки типов
  • path alias (tsconfig paths)

Webpack-резолвер может по-разному трактовать пути, создавая “невидимые” циклы.

Особенности в монорепозиториях

В монорепо циклы часто выходят за пределы одного пакета:

  • shared/utils → app/core → shared/utils

Особенно это характерно для архитектур с shared-layer, где отсутствует строгая направленность зависимостей.

Webpack в таких случаях видит цикл только на уровне итогового графа, а не пакетов, что усложняет диагностику.

Влияние на производительность и кеширование

Циклические зависимости не всегда критичны по производительности, но они влияют на:

  • стабильность кеша модулей
  • повторные вычисления при HMR
  • предсказуемость tree-shaking

Tree-shaking в Webpack может работать некорректно, если экспорт зависит от циклически инициализированных значений.

Практика построения устойчивой архитектуры

Устойчивые к циклам системы обычно строятся по принципу направленного графа:

  • UI → services → domain → infrastructure
  • отсутствие обратных импортов
  • явное управление зависимостями

Любое отклонение от этого направления быстро приводит к появлению скрытых циклов, особенно в больших кодовых базах.

Отладка сложных циклов

При работе с плагином circular-dependency-plugin полезно:

  • включать логирование полного пути импорта
  • временно запрещать части графа через exclude
  • изолировать подозрительные модули
  • проверять entry points отдельно

Иногда цикл проявляется только через конкретный entry chunk, что требует анализа нескольких сборок с разными точками входа.

Поведение в runtime и кеш модулей

Внутренний кеш Webpack создаёт иллюзию “устойчивости” циклов. Однако:

  • первый импорт фиксирует состояние модуля
  • последующие импорты не переисполняют код
  • экспорт остаётся “замороженным” на момент инициализации

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

Циклические зависимости — это не просто технический дефект, а индикатор нарушенной структуры зависимостей в графе модулей, который Webpack вынужден исполнять строго последовательно, даже если исходный код предполагает обратное.