Круг зависимостей: свой CommonJS против настоящего ESM
Здесь будет встроен видео-разбор темы (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» и какие два значения лимита окажутся в строке про Аню?
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?
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 и совпадут ли объекты?
Счётчик 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?
notes: начало не напечаталось вовсе. Первым выполнился users, create для него уже функция, а на limit выполнение оборвалось ReferenceError: имя привязано к переменной, у которой ещё нет значения. Отказ пришёл в промис import("notes"), поэтому пример и печатает только имя ошибки.Текст сообщения у Chrome и Firefox разный, поэтому в песочнице напечатано только имя ошибки, а подлинный текст покажет следующий раздел: там те же модули запускает настоящий Node.
Сравните с CommonJS. Там круг отдаёт undefined без всякого исключения, и ошибка всплывает где-то дальше, в чужой функции. ESM падает на той самой строке, где читается недописанное. А если чтение отложить в функцию, живая привязка решит задачу сама: к вызову format переменная уже заполнена. Круг в ESM безопасен, пока на верхнем уровне модуля никто не читает то, что объявлено по другую сторону круга.
| CommonJS | ESM | |
|---|---|---|
| что получает тот, кто подключает | объект exports таким, какой он сейчас | привязку к переменной модуля |
| кто в круге выполняется первым | подключённый первым — до своей строки с require, затем второй целиком | зависимость, целиком и раньше того, кто её импортирует |
| чтение того, что ещё не записано | undefined и предупреждение Node | ReferenceError; объявления function уже готовы |
| значение изменилось позже | деструктуризация и exports.x = x держат старое | видно через любой импорт |
| модуль подменяет то, что отдаёт | module.exports = …: у получивших раньше старый объект навсегда | такого нет: экспорт — переменная модуля |
Модели наши, поэтому сверим их с Node. Первый прогон — файлы из раздела про CommonJS без единой правки. Расширение .cjs говорит Node «это CommonJS», а .mjs — «это ES-модуль» при любых настройках проекта; как то же объявляют полем type в package.json, разберёт статья «package.json как договор». Прогноз: совпадут ли строки с моделью и где встанут предупреждения?
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: конец");
notes: начало users: начало users видит notes.create: undefined users: конец Аня (limit undefined, notes.limit 100): привет notes: конец (node:<pid>) Warning: Accessing non-existent property 'limit' of module exports inside circular dependency (Use `node --trace-warnings ...` to show where the warning was created) (node:<pid>) Warning: Accessing non-existent property 'create' of module exports inside circular dependency
Шесть строк слово в слово повторяют модель: 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: у файла на диске папка есть. Прогноз: какие строки успеют напечататься и с каким кодом завершится процесс?
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: конец");
users: начало
users видит create: function
file://<пример>/users.mjs:7
console.log("users видит limit:", limit);
^
ReferenceError: Cannot access 'limit' before initialization
at file://<пример>/users.mjs:7:35
at <внутренние кадры node>
Node.js v<версия>Всё как в песочнице: 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. Пример должен напечатать три строки без ошибки.
Лимит переезжает в модуль, который ни от кого не зависит:
Вывод:
Граф стал цепочкой notes → users → limits. Порядок выполнения по-прежнему от зависимостей — limits, users, notes, — но теперь каждый модуль к своей первой строке получает всё готовым. Если лимит понадобится и notes, он тоже импортирует его из limits.
Второй выход — оставить круг, но убрать limit из строки «users: начало» и читать его только в format: к вызову живая привязка уже заполнена, и строка про Аню выйдет с лимитом 100. Это работает, пока кто-нибудь не допишет в users наверх новое чтение лимита.
Найдите круг в графе
Прежде чем чинить круг, его надо найти, а в настоящем проекте в круге бывает и пять файлов. Ниже граф: кто кого импортирует. Допишите findCycle: она обходит граф от точки входа и возвращает первый найденный круг списком файлов, где первый и последний совпадают, или null, если круга нет. Учтите, что файл, к которому ведут две дороги, — ещё не круг.
Обходу нужны два множества:
Вывод:
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, чтобы вторая строка стала «сохранён Борис».
Порядок загрузки: main подключает user, user первой строкой подключает store, store просит user и получает из кеша недописанный exports — пустой объект. Затем user заменяет module.exports классом, но переменная User в store по-прежнему указывает на пустой объект, и new на нём бросает. Сохранение Ани работает, потому что user читает store.save при вызове, у законченного объекта.
Подключите user.cjs там, где класс нужен:
Вывод:
К вызову create модуль user загружен, и кеш отдаёт итоговый module.exports — класс. Повторные вызовы дешёвые: файл второй раз не читается, require только находит запись в кеше. Второй выход — в самом user.cjs: объявить класс и присвоить module.exports = User раньше строки с require("./store.cjs"). Он тоже работает, но держится на порядке строк, который легко переставить при правке.