Круг зависимостей: свой CommonJS против настоящего ESM

платформа≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В первой задаче своего сборщика два файла подключали друг друга, и b увидел undefined там, где настоящий ESM напечатал function. В сборщике это было любопытным расхождением, а на сервере это частая поломка: модуль заметок подключает модуль пользователей, пользователи — заметки, и одно из полей приходит пустым. Сегодня разберём, почему так выходит в CommonJS и как Node предупреждает о круге, запустим прямо в песочнице настоящий ESM с живыми привязками и подлинным ReferenceError, сверим обе модели с node и соберём приёмы, которыми круг разрывают.

Разделы темы:·

В статье про require загрузчик клал пустой module в кеш до того, как выполнить тело файла. Строчка незаметная, но на ней держится весь круг. Пусть notes.cjs первым делом подключает users.cjs, а тот — notes.cjs. Загрузчик начинает notes, доходит до require и начинает users, а users просит notes. Выполнять notes второй раз загрузчик не станет: запись уже в кеше. И users получает объект exports таким, какой он сейчас, — тело notes остановилось на строке с require и ещё ничего в него не записало.

Проверим на модели. load из статьи про require лежит в подложке, а файлы — те же, что потом пойдут в настоящий node. users берёт notes двумя способами: целым объектом и деструктуризацией поля limit. Функцию format, которая читает лимит обоими путями, notes вызовет, когда допишет свои поля. Прогноз: что напечатает строка «users видит notes.create» и какие два значения лимита окажутся в строке про Аню?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
notes.create при загрузке users — undefined: до этой строки notes ещё не дошёл. А в строке про Аню один и тот же лимит пришёл двумя значениями. notes.limit читается при вызове format, когда notes уже записал сотню, и находит её: объект тот же, поля в нём появились позже. limit из деструктуризации — отдельная переменная модуля users, и значение в неё скопировано один раз, при загрузке, когда поля ещё не было. Больше её никто не обновит.

Правило чтения CommonJS-круга: опасно не само подключение, а чтение во время загрузки. Всё, что модуль в круге берёт у соседа на верхнем уровне файла, — деструктуризация, вызов его функции, проверка поля — берётся из недописанного объекта. То, что читается позже, внутри функций, видит объект законченным.

Кто из двух получит половину, решает порядок подключения, а не сами файлы. Запустите первым users.cjs — и недописанный объект достанется уже notes, а его вызов users.format упадёт с TypeError.

Такое undefined ничем не отличается от честного, поэтому Node подсказывает: когда модуль в круге читает у недописанного exports поле, которого там нет, печатается предупреждение Accessing non-existent property … of module exports inside circular dependency. Устроено оно небольшим приёмом, который стоит повторить: в прототип недописанного объекта Node ставит Proxy — объект, чья ловушка get срабатывает на чтение поля, как в модели реестра нативных модулей.

Почему прототип, а не обёртка вокруг exports. Обращение к полю сначала ищет его в самом объекте и только не найдя идёт по прототипу — значит, ловушка get в прототипе срабатывает ровно на отсутствующих полях, а записанные читаются как обычно. Объект при этом остаётся тем же самым: users держит ссылку на настоящий exports и увидит поля, когда notes их допишет. Обёртку пришлось бы отдавать вместо объекта, и снять её после загрузки было бы нельзя: ссылка на неё уже осталась бы у users. А прототип загрузчик просто возвращает на место, когда тело модуля выполнено.

Перепишем load. Если модуль найден в кеше, но ещё не loaded, — это круг, и загрузчик ставит сторожа в прототип, когда там обычный Object.prototype. После выполнения тела сторож снимается. Файлы прежние. Прогноз: сколько предупреждений напечатается, про какие поля и будет ли предупреждение, когда format прочитает notes.limit?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Предупреждений два, про limit и create, и оба при загрузке users: деструктуризация — такое же чтение поля, как notes.create. А notes.limit внутри format прошёл тихо: к вызову поле уже есть в самом объекте, и до прототипа поиск не доходит.

Проверка Object.prototype перед установкой — тоже как в Node: сторож встаёт только на обычный объект. Если модуль успел заменить module.exports функцией или классом, подменённый прототип отнял бы у функции унаследованные call и bind.

Самый злой вариант круга — модуль, который отдаёт себя через module.exports = …. Тот, кто получил объект раньше, остаётся со старым пустым объектом навсегда: присваивание поменяло поле module.exports, а не содержимое объекта, который уже раздан. Сторожа Node снимает только с того, что лежит в module.exports после выполнения тела, — со старого объекта его никто не снимет. Поэтому предупреждение, пришедшее, когда все модули давно загружены, почти всегда значит одно: у кого-то в руках старый объект модуля, который заменил module.exports.

TypeScript, собирая import и export в CommonJS, в начале файла ставит exports.__esModule, а обращения пишет как notes_1.limit — чтением поля при каждом использовании. На такой exports Node сторожа не ставит, и в скомпилированном проекте круг проходит без предупреждения.

Теперь ES-модули. Их загрузчик моделировать не нужно: он встроен в браузер, а связывание и порядок выполнения модулей задаёт сама спецификация языка — те же, что у Node. Песочнице учебника не хватает только файлов. Имя модуля превращает в адрес карта импортов — та, что давала адрес пакету dayjs, а файл заменяет Blob: URL.createObjectURL выдаёт куску текста адрес вида blob:…, и import загружает по нему модуль, как по адресу файла.

Функция modules в подложке делает оба шага: каждому исходнику — blob-адрес, всем именам — одна карта импортов на странице примера. Модули поэтому подключают друг друга именами из карты, а не путями: относительный путь считается от адреса модуля, а у blob-адреса нет папки. Дальше import("main") запускает настоящий граф модулей.

Сравним счётчик в двух системах. Сначала по-старому: counter.cjs кладёт count в exports и отдаёт функцию, которая его увеличивает; загружает его load из статьи про require. Потом тот же счётчик ES-модулем, а main импортирует его дважды: именами и целиком, через import * as. В конце main ещё раз просит counter через import() и сравнивает результат с тем, что дал import * as. Прогноз: какой count напечатают после двух вызовов increment и совпадут ли объекты?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Счётчик CommonJS остался нулём, хотя increment отработал дважды. exports.count = count скопировал число в поле объекта при загрузке, и дальше переменная count внутри модуля и поле exports.count живут отдельно. ESM напечатал 2: import { count } не копирует значение, а связывает имя в main с переменной count в counter. Это называют живой привязкой: читать через неё — всё равно что читать саму переменную модуля, сколько бы раз та ни менялась.

Объект модуля тоже один. import * as counter и import("counter") вернули одно и то же пространство имён: модуль с одним адресом браузер выполняет один раз, как обещала статья о модулях, и дальше всем отдаёт один результат. Писать в привязку, правда, нельзя: count = 5 в main бросит TypeError — менять переменную может только модуль, который её объявил.

Вернёмся к кругу. Порядок у ES-модулей другой, и объясняет его статичность импортов: граф известен до выполнения первой строки. Поэтому загрузка идёт в три этапа. Сначала браузер или Node собирает все модули графа, читая их import. Потом связывает: у каждого модуля заводятся переменные его экспортов, и имена импортов подключаются к ним. И только потом выполняет тела — от зависимостей к тем, кто их импортирует. В круге обход начинает с notes, спускается в users, из users натыкается на уже начатый notes и возвращается. Первым выполняется тело users, за ним — notes.

Что увидит users, если на верхнем уровне читает limit? Переменная в notes уже заведена связыванием, но до строки export const limit = 100 дело не дошло, и она во временной мёртвой зоне, как любой const до объявления. Объявления функций устроены иначе: они готовы ещё при связывании, до выполнения тела. Именно это в задаче сборщика заставило настоящий ESM напечатать function.

Модули те же, что в CommonJS, только на import и export. Прогноз: какой модуль напечатает «начало» первым, что покажет typeof create и чем кончится чтение limit?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
notes: начало не напечаталось вовсе. Первым выполнился users, create для него уже функция, а на limit выполнение оборвалось ReferenceError: имя привязано к переменной, у которой ещё нет значения. Отказ пришёл в промис import("notes"), поэтому пример и печатает только имя ошибки.

Текст сообщения у Chrome и Firefox разный, поэтому в песочнице напечатано только имя ошибки, а подлинный текст покажет следующий раздел: там те же модули запускает настоящий Node.

Сравните с CommonJS. Там круг отдаёт undefined без всякого исключения, и ошибка всплывает где-то дальше, в чужой функции. ESM падает на той самой строке, где читается недописанное. А если чтение отложить в функцию, живая привязка решит задачу сама: к вызову format переменная уже заполнена. Круг в ESM безопасен, пока на верхнем уровне модуля никто не читает то, что объявлено по другую сторону круга.

CommonJSESM
что получает тот, кто подключаетобъект exports таким, какой он сейчаспривязку к переменной модуля
кто в круге выполняется первымподключённый первым — до своей строки с require, затем второй целикомзависимость, целиком и раньше того, кто её импортирует
чтение того, что ещё не записаноundefined и предупреждение NodeReferenceError; объявления function уже готовы
значение изменилось позжедеструктуризация и exports.x = x держат староевидно через любой импорт
модуль подменяет то, что отдаётmodule.exports = …: у получивших раньше старый объект навсегдатакого нет: экспорт — переменная модуля

Модели наши, поэтому сверим их с Node. Первый прогон — файлы из раздела про CommonJS без единой правки. Расширение .cjs говорит Node «это CommonJS», а .mjs — «это ES-модуль» при любых настройках проекта; как то же объявляют полем type в package.json, разберёт статья «package.json как договор». Прогноз: совпадут ли строки с моделью и где встанут предупреждения?

прогон node 26.5.0 · 15 сентября 2026
Файлы те же, что в модели, точка входа — notes.cjs
console.log("notes: начало");
const users = require("./users.cjs");
exports.limit = 100;
exports.create = (author, text) => users.format(author) + ": " + text;
console.log(exports.create("Аня", "привет"));
console.log("notes: конец");
node notes.cjs

Шесть строк слово в слово повторяют модель: notes.create при загрузке пустой, деструктурированный limit навсегда undefined, notes.limit при вызове — 100. Предупреждений два, про те же поля, но стоят они не на месте обращения, а в самом конце. Node не печатает их внутри get: он передаёт текст в process.emitWarning, а тот выводит предупреждение в поток ошибок, когда синхронный код уже закончился. (node:<pid>) — номер процесса, при записи его заменили меткой. Где именно случилось обращение, покажет флаг из подсказки: node --trace-warnings notes.cjs печатает под каждым предупреждением стек, и первая его строка, которая ведёт не во внутренности Node, а в ваш файл, — это users.cjs со строкой и столбцом обращения.

Второй прогон — те же модули файлами notes.mjs и users.mjs. От песочницы они отличаются только путями в import: у файла на диске папка есть. Прогноз: какие строки успеют напечататься и с каким кодом завершится процесс?

прогон node 26.5.0 · 15 сентября 2026
Точка входа — notes.mjs
import { format } from "./users.mjs";
console.log("notes: начало");
export const limit = 100;
export function create(author, text) {
  return format(author) + ": " + text;
}
console.log(create("Аня", "привет"));
console.log("notes: конец");
node notes.mjs

Всё как в песочнице: users выполнился первым и упал на limit, до notes: начало дело не дошло. Текст Cannot access 'limit' before initialization — от движка V8, поэтому Chrome пишет так же, а Firefox — по-своему. Над ним Node показал файл, строку и стрелку под именем, а необработанная ошибка завершила процесс с кодом 1. <пример>, <внутренние кадры node> и v<версия> — метки записи: полный путь у каждого свой, а номера строк во внутренних файлах и сама версия Node меняются от выпуска к выпуску.

Круг чинят не наугад, а от места чтения. Сначала найдите, что читается во время загрузки: в CommonJS это поле из предупреждения и строка из --trace-warnings, в ESM — строка со стрелкой. Дальше — один из четырёх приёмов, от надёжного к хрупкому.

ПриёмКак выглядитКогда годится
вынести общее в третий модульlimit переезжает в limits, и users берёт его оттуда, а не у notesпочти всегда: круга больше нет, и порядок загрузки не важен
читать при вызове, а не при загрузкеCommonJS — const notes = require(…) и notes.limit внутри функции; ESM — то же чтение внутри функциимодули и правда зовут друг друга, но только из функций
подключать по требованиюrequire внутри функции, которой нужен соседCommonJS-модуль в круге заменил module.exports классом
положиться на объявления функцийexport function вместо export const format = () => …работает до безобидной правки: стрелка, класс или export default со стрелкой снова дают ReferenceError

Второй приём в CommonJS требует дисциплины: деструктуризация на верхнем уровне файла — самый частый способ его нарушить, и выглядит она невинно. В ESM тот же приём ничего не стоит, потому что привязки живые. Зато чтения на верхнем уровне ESM не прощает вовсе, и это его достоинство: круг с ошибкой падает при запуске, а не через неделю в редкой ветке кода.

Если круг проходит через ES-модуль, подключённый через require, половины не будет: Node 26 сразу бросает ошибку с кодом ERR_REQUIRE_CYCLE_MODULE.

Круг бывает и уровнем выше: два сервиса, которые требуют друг друга в конструкторе, замыкаются в контейнере зависимостей, и ловит такой круг уже контейнер, а не загрузчик модулей. Его разберёт статья «Nest can't resolve dependencies».

  • В круге CommonJS второй модуль получает из кеша недописанный exports: всё, что он читает у соседа при загрузке, — undefined, а поля, прочитанные позже через объект, уже на месте.
  • Деструктуризация и exports.x = x копируют значение один раз. Хуже всего module.exports = … посреди круга: получивший объект раньше остаётся со старым навсегда.
  • Node ставит в прототип недописанного exports прокси-сторож и предупреждает о чтении отсутствующего поля. Печатает он предупреждение после синхронного кода, место показывает --trace-warnings, а предупреждение после запуска — признак старого объекта.
  • import связывает имя с переменной модуля — это живая привязка, а не копия. Модуль с одним адресом выполняется один раз и всем отдаёт одно пространство имён.
  • ES-модули собирают и связывают граф до выполнения и выполняют зависимости раньше тех, кто их импортирует. В круге чтение const, let или класса с другой стороны на верхнем уровне — ReferenceError; объявления функций готовы заранее.
  • Круг разрывают, вынося общее в третий модуль. Если модули зовут друг друга, чтение переносят в функции, а в CommonJS подключают соседа по требованию. Ищут круг обходом с текущей дорогой, а не множеством «уже видели».

1/4В круге CommonJS users.js на верхнем уровне пишет const { limit } = require("./notes") и получает undefined. Спустя секунду notes давно записал limit = 100, и функция из users читает limit. Что она увидит?

2/4Точка входа a.mjs импортирует b.mjs, а b.mjs — a.mjs. В обоих первой строкой после импортов стоит console.log со своим именем. Чья строка напечатается первой?

3/4В ESM-круге модуль на верхнем уровне вызывает две функции с другой стороны круга, которым самим ничего не нужно: объявленную как export function check() и как export const format = () => …. Что будет?

4/4Все модули давно загружены, сервер отвечает на запросы, и вдруг Node печатает Accessing non-existent property 'format' of module exports inside circular dependency. О чём это говорит?

Третий модуль вместо круга

Это круг из раздела про ESM, сокращённый до сути: notes подключает users ради format, а users берёт у notes лимит и падает с ReferenceError. Разорвите круг: заведите модуль limits, из которого лимит возьмёт users, так, чтобы users больше не импортировал notes. Пример должен напечатать три строки без ошибки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Лимит переезжает в модуль, который ни от кого не зависит:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Граф стал цепочкой notes → users → limits. Порядок выполнения по-прежнему от зависимостей — limits, users, notes, — но теперь каждый модуль к своей первой строке получает всё готовым. Если лимит понадобится и notes, он тоже импортирует его из limits.

Второй выход — оставить круг, но убрать limit из строки «users: начало» и читать его только в format: к вызову живая привязка уже заполнена, и строка про Аню выйдет с лимитом 100. Это работает, пока кто-нибудь не допишет в users наверх новое чтение лимита.

Найдите круг в графе

Прежде чем чинить круг, его надо найти, а в настоящем проекте в круге бывает и пять файлов. Ниже граф: кто кого импортирует. Допишите findCycle: она обходит граф от точки входа и возвращает первый найденный круг списком файлов, где первый и последний совпадают, или null, если круга нет. Учтите, что файл, к которому ведут две дороги, — ещё не круг.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Обходу нужны два множества:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

path — файлы на текущей дороге от точки входа: встретить такой файл снова значит вернуться туда, откуда пришёл, — это и есть круг. done — файлы, обойдённые целиком: к ним можно прийти второй раз другой дорогой, и круга в этом нет. Если проверять только «уже видели», как seen в графе сборщика, кругом окажется db.js: к нему ведут и app.js, и notes.js. Такая версия печатает ["main.js", "app.js", "routes.js", "notes.js", "db.js"] — дорогу до второго прихода в db.js, а не круг.

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

Класс, который не конструктор

user.cjs отдаёт класс через module.exports и подключает store.cjs, чтобы сохранять пользователей. store.cjs подключает user.cjs, чтобы создавать их. main.cjs сохраняет Аню — это работает — и просит store создать Бориса, но вместо второй строки пример печатает TypeError; в Node текст этой ошибки — User is not a constructor. Выясните, что получил store.cjs вместо класса, и почините только store.cjs, чтобы вторая строка стала «сохранён Борис».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок загрузки: main подключает user, user первой строкой подключает store, store просит user и получает из кеша недописанный exports — пустой объект. Затем user заменяет module.exports классом, но переменная User в store по-прежнему указывает на пустой объект, и new на нём бросает. Сохранение Ани работает, потому что user читает store.save при вызове, у законченного объекта.

Подключите user.cjs там, где класс нужен:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

К вызову create модуль user загружен, и кеш отдаёт итоговый module.exports — класс. Повторные вызовы дешёвые: файл второй раз не читается, require только находит запись в кеше. Второй выход — в самом user.cjs: объявить класс и присвоить module.exports = User раньше строки с require("./store.cjs"). Он тоже работает, но держится на порядке строк, который легко переставить при правке.