Предупреждение CIRCULAR_DEPENDENCY

Предупреждение CIRCULAR_DEPENDENCY в Rollup появляется при обнаружении циклической зависимости в графе модулей. Под циклом понимается ситуация, когда модуль A импортирует модуль B, а модуль B (прямо или через цепочку импортов) возвращает зависимость обратно в модуль A. Такая структура нарушает линейную направленность графа зависимостей и усложняет статический анализ кода.

В контексте ES Modules циклы не всегда являются критической ошибкой, но они создают неопределённость порядка инициализации, влияют на tree-shaking и могут приводить к частично инициализированным значениям на этапе выполнения.


Механизм обнаружения циклов в Rollup

Rollup строит направленный граф модулей, начиная с входной точки и рекурсивно анализируя import и export. При построении графа фиксируются переходы между модулями. Если при обходе графа обнаруживается повторное посещение уже находящегося в стеке модуля, фиксируется цикл.

Упрощённо:

  • модуль добавляется в стек обхода
  • при встрече импорта выполняется переход в зависимость
  • если зависимость уже в стеке — обнаружен цикл
  • формируется предупреждение CIRCULAR_DEPENDENCY

Важно, что Rollup не всегда считает цикл ошибкой сборки, поэтому выводится предупреждение, а не исключение.


Типичный пример циклической зависимости

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

export const aValue = 'A' + bValue;
// b.js
import { aValue } from './a.js';

export const bValue = 'B' + aValue;

Здесь a.js → b.js → a.js образует цикл.

На этапе выполнения значение aValue или bValue может быть undefined в момент первого обращения, поскольку модуль ещё не завершил инициализацию.


Частые причины возникновения циклов

Баррельные файлы (index.js)

Одна из наиболее распространённых причин — реэкспорт через агрегирующие модули:

// index.js
export * from './user.js';
export * from './auth.js';
// user.js
import { login } from './index.js';

Проблема возникает, когда index.js становится точкой пересечения множества модулей, а внутренние модули начинают импортировать его обратно.


Сложные доменные связи

В архитектурах с насыщенной бизнес-логикой модули часто начинают ссылаться друг на друга:

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

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


Общие утилиты и “shared” модули

Модуль shared часто становится центральной точкой, в которую постепенно добавляется логика:

// shared.js
export { formatDate } from './date.js';
export { apiClient } from './api.js';

Если apiClient или date.js начинают импортировать что-то из shared.js, возникает замкнутый цикл.


UI-компоненты и взаимные зависимости

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

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

или через общий модуль состояния.


Поведение runtime при цикле

ES Modules имеют специфику ленивой инициализации экспортов. При цикле:

  • экспортируемые значения существуют, но могут быть неинициализированы
  • доступ к ним до завершения выполнения модуля даёт undefined
  • функции обычно безопаснее, чем значения, потому что инициализируются позже

Пример:

console.log(aValue); // undefined

Даже если позже значение будет установлено, первый доступ может вернуть некорректное состояние.


Влияние на tree-shaking и сборку

Циклические зависимости ухудшают возможности статического анализа:

  • Rollup сложнее определить достижимость кода
  • некоторые экспорты перестают считаться “pure”
  • увеличивается вероятность попадания лишнего кода в бандл
  • нарушается оптимальная топология графа

В сложных циклах Rollup может отказаться от агрессивного удаления неиспользуемого кода.


Примеры типичных циклов в реальных проектах

Цикл через API слой

// api.js
import { getToken } from './auth.js';

export function request() {
  return fetch('/data', {
    headers: { Authorization: getToken() }
  });
}
// auth.js
import { request } from './api.js';

export function getToken() {
  return localStorage.getItem('token');
}

Даже если логически зависимости кажутся оправданными, граф остаётся циклическим.


Цикл через модель и сервис

// user.model.js
import { validateUser } from './user.service.js';
// user.service.js
import { UserModel } from './user.model.js';

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


Способы устранения циклов

Разделение общего контракта

Общая зависимость выносится в отдельный модуль:

// user.types.js
export const USER_ROLES = {
  admin: 'admin',
  user: 'user'
};

Оба модуля начинают зависеть от него, но не друг от друга напрямую.


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

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

export function createApi(getToken) {
  return function request() {
    return fetch('/data', {
      headers: { Authorization: getToken() }
    });
  };
}

Это разрывает цикл на уровне импорта.


Ленивая загрузка через динамический import

export async function getService() {
  const mod = await import('./service.js');
  return mod.service;
}

Такой подход переносит зависимость из статического графа в runtime.


Удаление обратных импортов через рефакторинг слоёв

Классическая ошибка — смешивание уровней:

  • UI слой
  • бизнес-логика
  • инфраструктура

Перестройка слоёв позволяет убрать обратные связи.


Устранение barrel-cycle

Если цикл идёт через index.js, помогает:

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

Подавление предупреждения

Rollup позволяет подавлять предупреждение через конфигурацию:

export default {
  onwarn(warning, warn) {
    if (warning.code === 'CIRCULAR_DEPENDENCY') return;
    warn(warning);
  }
};

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


Особенности в monorepo и больших кодовых базах

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

  • package-a зависит от package-b
  • package-b зависит от package-a через shared-utils

Причина часто заключается в:

  • отсутствии строгих границ пакетов
  • общих внутренних утилитах
  • неправильной структуре public API

Rollup фиксирует такие циклы на уровне итогового бандла, даже если в отдельных пакетах они не видны.


Косвенные и “скрытые” циклы

Не все циклы очевидны. Возможны цепочки:

a → b → c → a

или более длинные:

a → utils → shared → core → a

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


Диагностика циклов

Для анализа графа используются:

  • вывод Rollup с указанием цепочки модулей
  • визуализация dependency graph
  • временное упрощение импортов
  • поэтапное отключение модулей

Rollup обычно выводит цепочку вида:

Circular dependency: a.js -> b.js -> a.js

или расширенную цепочку через несколько файлов.


Практическая интерпретация предупреждения

Наличие CIRCULAR_DEPENDENCY не всегда означает ошибку, но почти всегда сигнализирует о:

  • слабой модульной границе
  • смешении ответственности
  • потенциальных runtime-рисках
  • ухудшении предсказуемости инициализации

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