Circular dependencies: обнаружение и разрешение

Циклическая зависимость (circular dependency) возникает в ситуации, когда два или более модуля прямо или косвенно зависят друг от друга.

Простейший пример:

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

export const a = 'A';
console.log(b);
// b.js
import { a } from './a.js';

export const b = 'B';
console.log(a);

Здесь образуется цикл:

a.js → b.js
 ↑       ↓
 └───────┘

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

app.js
 ↓
router.js
 ↓
store.js
 ↓
api.js
 ↓
app.js

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


Почему циклические зависимости считаются проблемой

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

Однако такие конструкции создают несколько серьёзных проблем.

Неопределённый порядок инициализации

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

Например:

// config.js
import { apiUrl } from './api.js';

export const config = {
    apiUrl
};
// api.js
import { config } from './config.js';

export const apiUrl = config.apiUrl;

Во время загрузки один из модулей ещё не успевает полностью инициализироваться.

Результатом могут стать:

undefined

или

ReferenceError

в зависимости от конкретного сценария.


Сложность сопровождения

При чтении кода разработчик обычно ожидает однонаправленный поток зависимостей:

UI → Services → Data

Циклы нарушают эту структуру:

UI ↔ Services

или

UI → Services → Store → UI

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


Ухудшение tree shaking

Tree shaking анализирует граф зависимостей.

Когда в графе присутствуют циклы, Rollup приходится учитывать дополнительные сценарии использования экспортов.

Это может приводить к сохранению кода, который при отсутствии циклов был бы удалён.


Сложности при разделении кода

Code splitting основан на построении графа модулей.

Наличие большого количества циклов может затруднить формирование независимых чанков:

chunk-a
   ↕
chunk-b

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


Как Rollup строит граф модулей

Перед генерацией бандла Rollup анализирует все импорты.

Например:

import { render } from './ui.js';
import { getData } from './api.js';

На основании импортов создаётся ориентированный граф.

Пример:

main.js
 ├─ ui.js
 ├─ api.js
 │   └─ config.js
 └─ utils.js

После построения графа Rollup определяет:

  • порядок выполнения модулей;
  • связи между экспортами;
  • возможность tree shaking;
  • структуру будущих чанков.

Если во время обхода обнаруживается возврат к уже посещённому узлу, возникает цикл.


Обнаружение циклических зависимостей в Rollup

Rollup автоматически анализирует граф зависимостей.

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

(!) Circular dependency
src/a.js -> src/b.js -> src/a.js

Это означает, что сборка продолжается, но найден потенциально опасный цикл.


Пример предупреждения

Модуль A:

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

export const a = b + 1;

Модуль B:

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

export const b = a + 1;

Во время сборки можно увидеть:

(!) Circular dependency
a.js -> b.js -> a.js

Rollup показывает полный маршрут, по которому найден цикл.


Косвенные циклы

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

Пример:

a.js → b.js
b.js → c.js
c.js → a.js

Код:

// a.js
import './b.js';
// b.js
import './c.js';
// c.js
import './a.js';

Rollup отобразит полный путь:

(!) Circular dependency
a.js -> b.js -> c.js -> a.js

Такие циклы особенно трудно обнаруживать вручную.


Поведение ES Modules при циклах

В отличие от старых систем модулей, ES Modules поддерживают циклические зависимости на уровне спецификации.

Рассмотрим пример.

// a.js
export const value = 10;
// b.js
import { value } from './a.js';

console.log(value);

В обычной ситуации всё работает предсказуемо.

Теперь добавим цикл:

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

export const a = 1;

console.log(b);
// b.js
import { a } from './a.js';

export const b = 2;

console.log(a);

Результат зависит от момента обращения к переменным.

Если экспорт ещё не инициализирован, возникает проблема временной мёртвой зоны (Temporal Dead Zone).

Например:

console.log(a);

может привести к:

ReferenceError:
Cannot access 'a' before initialization

Использование сторонних инструментов для поиска циклов

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

Популярным решением является анализ графа зависимостей.

Например, инструмент может построить такую схему:

components/Button.js
    ↓
store/theme.js
    ↓
utils/colors.js
    ↓
components/Button.js

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


Архитектурные причины появления циклов

Чаще всего циклы возникают из-за нарушения разделения ответственности.

Взаимная зависимость сервисов

Неправильный вариант:

// userService.js
import { authService } from './authService.js';
// authService.js
import { userService } from './userService.js';

Оба сервиса знают друг о друге.


Связь слоя интерфейса с бизнес-логикой

UI → Store
 ↑      ↓
 └──────┘

Компоненты начинают импортировать хранилище, а хранилище импортирует компоненты.

Архитектура становится замкнутой.


Общий файл превращается в центр зависимостей

Например:

// index.js
export * from './a.js';
export * from './b.js';
export * from './c.js';

Позже один из модулей начинает импортировать этот агрегирующий файл:

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

Если b.js уже зависит от текущего модуля, появляется скрытый цикл.


Способы устранения циклических зависимостей

Выделение общего модуля

Наиболее распространённый способ.

Было:

a.js ↔ b.js

Стало:

      common.js
      ↑      ↑
      │      │
    a.js   b.js

Пример.

До рефакторинга:

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

После рефакторинга:

// common.js
export const VERSION = '1.0';
// a.js
import { VERSION } from './common.js';
// b.js
import { VERSION } from './common.js';

Цикл исчезает.


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

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

Было:

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

Стало:

export function createService(logger) {
    return {
        start() {
            logger.log('start');
        }
    };
}

Теперь модуль не зависит от конкретной реализации.


Использование событий

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

Было:

ModuleA ↔ ModuleB

Стало:

ModuleA → EventBus ← ModuleB

Пример:

eventBus.emit('user-login');
eventBus.on('user-login', callback);

Модули становятся независимыми.


Вынесение типов и констант

Часто цикл создаётся только из-за нескольких констант.

Плохая структура:

user.js ↔ permissions.js

Лучше:

constants.js
   ↑      ↑
   │      │
user.js permissions.js

Разделение ответственности

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

Вместо:

cart.js ↔ checkout.js

можно создать:

commerce/
    cart.js
    checkout.js
    order.js

или объединить части логики в один модуль.


Когда циклическая зависимость допустима

Не каждый цикл обязательно является ошибкой.

Иногда цикл используется осознанно и работает корректно благодаря механизму живых привязок (live bindings) ES Modules.

Пример:

// a.js
export let counter = 0;

export function increment() {
    counter++;
}
// b.js
import { counter } from './a.js';

console.log(counter);

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

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


Практические рекомендации

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

Application
     ↓
Services
     ↓
Repositories
     ↓
Utilities

Зависимости должны направляться сверху вниз.

Нежелательная структура:

Application
 ↕
Services
 ↕
Repositories

Полезные правила:

  • избегать взаимных импортов между модулями одного уровня;
  • не импортировать агрегирующие index.js внутри модулей той же директории;
  • выделять общую функциональность в отдельные модули;
  • регулярно проверять предупреждения Rollup;
  • рассматривать появление цикла как сигнал возможной архитектурной проблемы;
  • проектировать слои приложения с однонаправленным потоком зависимостей;
  • устранять циклы на ранних этапах разработки, пока граф модулей остаётся простым и понятным.

При грамотной архитектуре граф зависимостей остаётся ациклическим, что упрощает анализ модулей, повышает эффективность tree shaking, облегчает code splitting и делает сборку Rollup более предсказуемой.