Циклическая зависимость (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 анализирует граф зависимостей.
Когда в графе присутствуют циклы, Rollup приходится учитывать дополнительные сценарии использования экспортов.
Это может приводить к сохранению кода, который при отсутствии циклов был бы удалён.
Code splitting основан на построении графа модулей.
Наличие большого количества циклов может затруднить формирование независимых чанков:
chunk-a
↕
chunk-b
Вместо изолированных частей приложения образуется плотный клубок взаимосвязей.
Перед генерацией бандла Rollup анализирует все импорты.
Например:
import { render } from './ui.js';
import { getData } from './api.js';
На основании импортов создаётся ориентированный граф.
Пример:
main.js
├─ ui.js
├─ api.js
│ └─ config.js
└─ utils.js
После построения графа 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 поддерживают циклические зависимости на уровне спецификации.
Рассмотрим пример.
// 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 внутри модулей
той же директории;При грамотной архитектуре граф зависимостей остаётся ациклическим, что упрощает анализ модулей, повышает эффективность tree shaking, облегчает code splitting и делает сборку Rollup более предсказуемой.