Циклическая зависимость (Circular Dependency) возникает в ситуации, когда два или более модуля прямо или косвенно зависят друг от друга. В простейшем случае модуль A импортирует модуль B, а модуль B импортирует модуль A.
Пример:
// a.js
import { b } from './b.js';
export const a = () => {
return b();
};
// b.js
import { a } from './a.js';
export const b = () => {
return a();
};
Подобная структура создает замкнутый граф зависимостей:
a.js → b.js
↑ ↓
└──────┘
В небольших проектах такие циклы могут оставаться незаметными, однако при использовании code splitting проблема становится значительно сложнее. Rollup начинает разбивать граф модулей на отдельные чанки, и циклические связи начинают влиять на порядок загрузки, инициализацию экспортов и корректность выполнения приложения.
Сам факт существования цикла не всегда приводит к ошибке. Стандарт ES Modules допускает циклические импорты. Однако возникает несколько потенциальных проблем.
Во время загрузки модулей некоторые экспорты могут быть еще не готовы к использованию.
Пример:
// user.js
import { role } from './permissions.js';
export const user = {
role
};
// permissions.js
import { user } from './user.js';
export const role = user?.role ?? 'guest';
Во время выполнения один из модулей может обратиться к значению до его полной инициализации.
Результатом становятся:
undefined
ReferenceError
неожиданные значения
При наличии цикла становится сложно определить, какой модуль будет полностью выполнен первым.
Например:
// a.js
console.log('A start');
import './b.js';
console.log('A end');
// b.js
console.log('B start');
import './a.js';
console.log('B end');
Порядок выполнения зависит от механизма обработки модулей и особенностей построения графа зависимостей.
Большие циклы превращают архитектуру приложения в запутанный клубок зависимостей.
Пример:
router.js
↓
store.js
↓
api.js
↓
auth.js
↓
router.js
Через несколько месяцев становится трудно определить источник ошибки или понять последствия изменения одного из модулей.
Rollup строит полный граф зависимостей проекта.
Упрощенный пример:
main.js
├─ dashboard.js
├─ users.js
└─ settings.js
После анализа импортов создается ориентированный граф.
При обнаружении цикла:
dashboard.js
↓
charts.js
↓
widgets.js
↓
dashboard.js
Rollup объединяет информацию о связанных модулях и пытается сохранить корректный порядок исполнения.
Для обычной сборки это зачастую не вызывает серьезных проблем. Но при разделении на чанки ситуация усложняется.
Рассмотрим структуру приложения:
main.js
├─ admin.js
└─ profile.js
Модули загружаются динамически:
await import('./admin.js');
await import('./profile.js');
Rollup создает:
main.js
admin.chunk.js
profile.chunk.js
Предположим, что внутри этих частей возникает цикл.
admin.js
↓
permissions.js
↓
profile.js
↓
admin.js
Теперь проблема распространяется уже не только на модули, но и на сами чанки.
Получается структура:
admin.chunk.js
↓
profile.chunk.js
↓
admin.chunk.js
Возникают вопросы:
Особенно опасны циклы между разными чанками.
Пример:
// admin.js
import { profileData } from './profile.js';
export function getAdminData() {
return profileData;
}
// profile.js
import { getAdminData } from './admin.js';
export const profileData = getAdminData();
После сплиттинга:
admin.chunk.js
profile.chunk.js
Каждый чанк требует другой для завершения собственной инициализации.
В подобных случаях возможны:
undefined;Во время сборки Rollup способен обнаруживать циклы.
Типичное предупреждение выглядит так:
Circular dependency:
src/admin.js -> src/profile.js -> src/admin.js
Более сложный вариант:
Circular dependency:
src/router.js
→ src/store.js
→ src/api.js
→ src/auth.js
→ src/router.js
Такие предупреждения нельзя игнорировать автоматически.
Хотя приложение может успешно собраться, предупреждение сигнализирует о потенциальной архитектурной проблеме.
Без code splitting весь проект часто оказывается в одном бандле.
bundle.js
Даже если внутри существуют циклы, движок JavaScript работает с единой структурой модулей.
После разделения появляются отдельные файлы:
main.js
vendor.js
admin.chunk.js
profile.chunk.js
reports.chunk.js
Теперь необходимо учитывать:
Чем больше чанков, тем выше вероятность столкнуться с последствиями циклических зависимостей.
Рассмотрим пример.
// auth.js
import { api } from './api.js';
export function login() {
return api.login();
}
// api.js
import { getToken } from './authState.js';
export const api = {
login() {},
request() {
return getToken();
}
};
// authState.js
import { login } from './auth.js';
export function getToken() {
login();
}
Получается цикл:
auth.js
↓
api.js
↓
authState.js
↓
auth.js
Когда эти файлы оказываются в разных чанках, вероятность проблем возрастает.
Наиболее распространенное решение — вынести общую функциональность в отдельный модуль.
До рефакторинга:
admin.js ↔ profile.js
После рефакторинга:
admin.js
↓
shared.js
↑
profile.js
Пример:
// shared.js
export function formatUser(user) {
return `${user.name}`;
}
// admin.js
import { formatUser } from './shared.js';
// profile.js
import { formatUser } from './shared.js';
Цикл исчезает.
Еще один подход — внедрение промежуточного слоя.
Плохая схема:
UI ↔ Store
Хорошая схема:
UI
↓
Services
↓
Store
Например:
UI
↓
UserService
↓
Store
Компоненты интерфейса больше не импортируют состояние напрямую, что снижает вероятность образования циклов.
Иногда цикл возникает из-за неправильного направления импортов.
Плохой вариант:
Component
↓
Store
↓
Component
После инверсии:
Component
↓
Store
Store
↓
Interface
Логика начинает зависеть от абстракции, а не от конкретной реализации.
Это один из фундаментальных способов устранения циклических связей в крупных приложениях.
В некоторых случаях помогает перенос обращения к модулю из области импорта в область выполнения.
Проблемный вариант:
import { service } from './service.js';
const result = service.execute();
Безопасный вариант:
import { service } from './service.js';
export function run() {
return service.execute();
}
Модуль импортируется сразу, но обращение к нему происходит только после завершения инициализации графа зависимостей.
Иногда цикл можно разорвать через динамическую загрузку.
До рефакторинга:
import { report } from './report.js';
После:
async function loadReport() {
const module = await import('./report.js');
return module.report;
}
Теперь зависимость становится отложенной.
Однако данный подход следует использовать осмотрительно. Если динамический импорт применяется исключительно для обхода архитектурной проблемы, структура проекта остается уязвимой.
Если все связанные модули попали в один чанк:
app.chunk.js
├─ a.js
├─ b.js
└─ c.js
Rollup обычно способен сохранить корректную работу благодаря механизму живых экспортов (live bindings).
В большинстве случаев:
export let counter = 0;
и
import { counter } from './state.js';
будут работать корректно даже при наличии цикла.
Однако это не гарантирует безопасность логики, которая выполняется во время инициализации модулей.
Ситуация значительно сложнее.
Пример:
chunkA
↓
chunkB
↓
chunkC
↓
chunkA
Rollup старается:
Но полностью устранить логические проблемы он не может.
Если архитектура приложения содержит межчанковые циклы, сборщик не способен гарантировать корректное поведение бизнес-логики.
Циклические зависимости нередко проявляются только в готовом приложении.
Типичные симптомы:
undefined;Подобные симптомы часто указывают именно на скрытый цикл в графе зависимостей.
Минимизировать двусторонние импорты
Плохо:
A ↔ B
Лучше:
A → Shared ← B
Разделять слои приложения
UI
↓
Services
↓
Data
Зависимости должны идти сверху вниз.
Избегать выполнения сложной логики при загрузке модуля
Плохо:
const result = expensiveOperation();
Лучше:
export function getResult() {
return expensiveOperation();
}
Регулярно анализировать предупреждения Rollup
Сообщение о циклической зависимости следует рассматривать как повод для проверки архитектуры, особенно если проект активно использует code splitting.
Следить за межчанковыми связями
Чем больше независимы чанки друг от друга, тем проще их загрузка и тем меньше риск появления трудноуловимых ошибок.
Циклическая зависимость редко является изолированной технической проблемой. Чаще всего она указывает на нарушение границ ответственности между модулями. Если после включения code splitting начинают появляться предупреждения Rollup, это нередко означает, что отдельные части системы знают друг о друге слишком много.
Грамотная структура зависимостей обычно имеет направленный граф:
Application
↓
Features
↓
Services
↓
Infrastructure
В таком графе отсутствуют обратные связи, а разделение на чанки выполняется предсказуемо. Rollup может свободно оптимизировать загрузку модулей, формировать независимые части приложения и создавать эффективную стратегию code splitting без риска возникновения межчанковых циклов.