Собираем require: реестр, обёртка модуля, кеш
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье о модулях require описан одной фразой: находит файл, выполняет его один раз, запоминает exports в кеше. Две последние части вы уже собрали в своём сборщике — реестр и кеш. Первую за рантайм сделал сборщик: пути он разрешил ещё до запуска. У Node этапа сборки нет, и его require ищет файл сам, в момент вызова. Сегодня достроим его до поведения Node: поиск с расширениями, index.js и подъёмом по node_modules, обёртку с пятью параметрами и ловушку exports = …, кеш по разрешённому пути — и сверим модель с настоящим node.
Вспомните, во что обёртка сборщика превратила импорт: require("/src/money.js"). Путь в скобках уже полный, его разрешили на сборке, и рантайму оставалось найти эту строку в реестре. У Node такого этапа нет. require("./math") доходит до загрузчика ровно так, как написан, и загрузчик сам решает, какой файл имеется в виду, — прямо сейчас, пока вызывающий код ждёт ответа.
Для нашего сборщика зависимость была строчкой текста, которую нашла регулярка, а require в Node — обычная функция. Поэтому аргумент можно вычислить: require("./locales/" + lang) в Node работает, а сборщику из части IV вычисленный путь не найти — его регулярка берёт путь только из кавычек.
Правила поиска Node описывает в документации псевдокодом, и главное в нём умещается в абзац. Путь, который начинается с ./ или ../, склеивается с папкой вызывающего файла — не с папкой, из которой запустили процесс. Дальше пробуется файл как написан, потом с .js, потом с .json, и только после них — папка с index.js или index.json внутри. Голое имя вроде dayjs — пакет: загрузчик заглядывает в node_modules рядом с файлом, потом в node_modules папки выше и так до корня диска. Долгие годы так же искал файлы и TypeScript — это его режим node10.
Соберём резолвер. Диска в песочнице нет, поэтому проект — набор имён файлов, а join склеивает пути и разбирает ... Перебор кандидатов — тот же приём, что в резолвере расширений Metro, только кандидатов меньше. Прогноз: что достанется ./config, если в проекте есть и config.js, и папка config со своим index.js? И в каких папках резолвер поищет lodash, прежде чем сдаться?
./config стал файлом config.js: файлы проверяются раньше папки, и до config/index.js очередь не дошла. Сам config/index.js отсюда достают явным путём, например ./config/index.js. На этом спотыкаются при переезде: рядом с новой папкой config забыли старый config.js — и проект молча читает его. ./data нашёл data.json, потому что расширение перебирается так же, как .js. А dayjs из /app/lib/index.js не лежал в /app/lib/node_modules и нашёлся папкой выше.
Список папок у lodash и есть подъём: три node_modules от папки файла до корня. У настоящего Node этот список длиннее на несколько глобальных папок, а целиком его отдаёт require.resolve.paths("lodash"). Подъём объясняет и раскладку npm: плоский node_modules в корне проекта виден из любой вложенной папки, а версия, которую из-за конфликта положили глубже, внутрь папки пакета, находится первой — она ближе к файлу, который её просит. Перечень папок в сообщении — наша добавка: Node пишет только Cannot find module 'lodash' и цепочку файлов, которая к этому привела.
Расширения перебирает только require. import в Node, как и в браузере, ищет ровно тот файл, что написан: import из "./math" без .js падает с ERR_MODULE_NOT_FOUND.
Модель пропускает три вещи. Встроенные модули идут раньше всякого поиска: require("fs") вернёт встроенный node:fs, даже если в node_modules лежит пакет с именем fs. Кроме .js и .json пробуется .node — скомпилированное дополнение на C++. И в папке пакета сначала читается package.json: поле main разберём в первой задаче, а поле exports, которое у современного пакета важнее остальных, — в статье «package.json как договор».
Путь найден, теперь файл надо выполнить. В сборщике обёртка давала модулю два параметра, require и exports. Загрузчик Node делает из текста файла тело функции с пятью: exports, require, module, __filename и __dirname — и вызывает её. Из такой обёртки следуют три особенности CommonJS.
Первая — require у каждого модуля свой. Относительный путь считается от вызывающего файла, значит, функция должна этот файл помнить: загрузчик создаёт её заново для каждого модуля, с его путём в замыкании. Вторая — __filename и __dirname не глобальные переменные, а аргументы: полный путь файла и его папка. Третья — объект module. В кеш кладётся он, а не голый exports, и после выполнения тела загрузчик возвращает module.exports — то, что окажется в этом поле к концу.
Допишем загрузчик. resolve из прошлого примера лежит в подложке, а files теперь хранит исходники. Файл .json не выполняется, а разбирается прямо в module.exports. math.js отдаёт функции через exports.add и module.exports.sub, а format.js — присваиванием exports = { rub }. Прогноз: что напечатают строки math и format?
format пришёл пустым объектом, хотя функция rub в файле есть. Параметр exports — обычная локальная переменная, в которую загрузчик положил ссылку на module.exports. exports.add = … пишет поле в тот самый объект, поэтому math получил обе функции. А exports = { rub } перенаправляет переменную на новый объект: module.exports по-прежнему указывает на старый, пустой, и загрузчик возвращает именно его. Поэтому модуль, который отдаёт одну функцию или класс, пишет module.exports = …: так меняется поле, которое прочитает загрузчик.
Последняя строка показала, откуда берутся пути: __filename — это path, с которым загрузчик вызвал обёртку, __dirname — dirname(path). Раз имена приходят аргументами, в ES-модуле, который в такую функцию не заворачивается, их нет вовсе: папку файла там даёт import.meta.dirname. И ещё одна строка загрузчика важнее, чем кажется, — пустой module попадает в кеш до выполнения тела. Как показала первая задача сборщика, из двух модулей, которые подключают друг друга, второй получает недописанный объект первого, а не бесконечную рекурсию. Чем это кончается в настоящем Node и почему ES-модули ведут себя иначе, разберёт статья «Круг зависимостей».
Первый аргумент call — это this. На верхнем уровне CommonJS-модуля this равен module.exports, а в ES-модуле — undefined.
В реестре сборщика ключом кеша был путь, который сборщик уже разрешил. У Node так же, только разрешает сам require: сначала resolve, потом поиск в кеше. Строка из скобок ключом не бывает.
В примере счётчик номеров counter.js подключают двое: routes/notes.js — строкой "../counter", main.js — строкой "./counter.js". notes дважды создаёт заметку, потом main просит номер у счётчика сам. Прогноз: сколько раз напечатается «counter выполнен» и какой номер получит main?
Тело счётчика выполнилось один раз, и main получил тройку: первые два номера уже забрали заметки. "../counter" из /app/routes и "./counter.js" из /app разрешились в один путь, /app/counter.js, и под этим ключом в кеше лежал готовый объект со своей переменной count. На этом держится привычный приём Node: модуль, который создаёт пул соединений с базой или читает настройки, выполняется один раз, и все файлы проекта делят один пул и одни настройки — без глобальных переменных.
Кеш — обычный объект, и запись из него можно удалить. В Node это delete require.cache[путь]: к нему прибегают, когда тесту нужен модуль с чистым состоянием. Следующий require найдёт пустое место и выполнит тело заново. В подложке — файлы прошлого примера. Прогноз: какой номер выдаст свежий счётчик и какую заметку создаст notes, загруженный до удаления?
Свежий счётчик начал с единицы, а notes создал заметку 3. Он взял объект счётчика при своей загрузке, держит на него ссылку и об удалении из кеша не знает. В процессе теперь два счётчика, и номера у них расходятся. Удаление не перезагружает модуль, оно только разрешает следующему require выполнить тело ещё раз. Кто получил объект раньше, работает со старым. Поэтому честно перезагрузить модуль — значит убрать из кеша и всех, кто его подключил, по цепочке вверх, а проще перезапустить процесс целиком, как делает node --watch.
Модель наша, поэтому сверим её с настоящим Node. В примере пять файлов. main.cjs подключает папку lib, файл data.json и lib/format.js с присваиванием exports = …, а потом просит пакет dayjs, которого в примере нет. lib/index.js печатает, сколько аргументов получило его тело. Расширение .cjs говорит Node «это CommonJS» при любых настройках проекта. Файлы .js в lib Node тоже выполняет как CommonJS: над ними нет package.json, который объявил бы их ES-модулями, и в них самих нет ни import, ни export, по которым Node узнал бы ES-модуль. Прогноз: сколько аргументов у тела модуля, что отдаст format.js и в каком порядке встанут ключи require.cache?
const path = require("node:path"); const lib = require("./lib"); const settings = require("./data.json"); console.log("сумма:", lib.sum([2, 3, 4]), "| лимит из data.json:", settings.limit); console.log("./lib и ./lib/index.js — один объект:", lib === require("./lib/index.js")); const format = require("./lib/format"); console.log("format.js отдал:", format); console.log("ключи require.cache:"); for (const key of Object.keys(require.cache)) console.log(" " + key); try { require("dayjs"); } catch (err) { // без стека: в нём внутренние кадры node, а нужны код, первая строка и цепочка файлов console.log(err.code, "—", err.message.split("\n")[0]); console.log("requireStack:", err.requireStack.join(", ")); } // первые три папки: дальше путь уходит за пределы примера, и у каждого он свой const nearest = module.paths.slice(0, 3).map((dir) => path.relative(__dirname, dir)); console.log("пакеты ищутся в:", nearest.join(", "));
lib/index.js получил аргументов: 5
__filename: <пример>/lib/index.js
сумма: 9 | лимит из data.json: 20
./lib и ./lib/index.js — один объект: true
format.js отдал: {}
ключи require.cache:
<пример>/main.cjs
<пример>/lib/index.js
<пример>/lib/math.js
<пример>/data.json
<пример>/lib/format.js
MODULE_NOT_FOUND — Cannot find module 'dayjs'
requireStack: <пример>/main.cjs
пакеты ищутся в: node_modules, ../node_modules, ../../node_modulesТело lib/index.js получило пять аргументов — ровно список обёртки, — и __filename пришёл полным путём. <пример> в выводе — папка примера: полный путь у каждого свой, и при записи его заменили меткой. ./lib и ./lib/index.js дали один объект, data.json стал объектом без строчки кода, а format.js отдал {} — всё как в модели.
Ключи require.cache — полные пути. main.cjs стоит первым: главный файл тоже модуль, и в кеш он попал до выполнения своего тела. lib/math.js встал раньше data.json, потому что lib/index.js попросил его посреди собственной загрузки, ещё до того, как main.cjs добрался до следующей строки. node:path в кеше нет: встроенные модули хранятся отдельно и файлов не ищут.
Пакета dayjs не нашлось, и ошибка несёт всё нужное для поиска причины: код MODULE_NOT_FOUND и requireStack — цепочку файлов от того, кто просил, до главного. Здесь цепочка из одного main.cjs. Если бы пакет просил lib/index.js, первым в ней стоял бы он, а за ним main.cjs — такую цепочку вы соберёте в третьей задаче. Последняя строка — начало подъёма, тот же список, что печатала модель: node_modules рядом с файлом, потом на уровень выше и ещё выше.
В таблице прошлой статьи чтение файла стоит за потоком пула. К require это не относится: он возвращает результат сразу, ждать колбэка ему некогда, и файлы он проверяет и читает синхронно, в главном потоке. Проверим. Четыре долгих хеша занимают все четыре потока пула, после чего один и тот же файл берут require и асинхронный readFile. Полный путь для readFile отдаёт require.resolve: это тот же поиск, что у require, только без выполнения файла. Прогноз: кому из двоих придётся ждать?
const crypto = require("node:crypto"); const fs = require("node:fs"); // четыре долгих хеша занимают все четыре потока пула for (let i = 0; i < 4; i++) { crypto.pbkdf2("пароль", "соль " + i, 3_000_000, 32, "sha256", () => {}); } const start = performance.now(); const report = require("./report.js"); const required = performance.now() - start; console.log(`require вернул «${report.title}» быстрее 50 мс: ${required < 50 ? "да" : "нет"}`); fs.readFile(require.resolve("./report.js"), "utf8", () => { const read = performance.now() - start; console.log(`readFile того же файла ответил позже 100 мс: ${read > 100 ? "да" : "нет"}`); });
require вернул «отчёт за сентябрь» быстрее 50 мс: да readFile того же файла ответил позже 100 мс: да
require вернулся сразу, хотя пул был занят, — в пробных запусках за доли миллисекунды. readFile того же файла ответил позже 100 мс, в пробах — около четырёхсот миллисекунд: его чтение ждало, пока освободится поток. При свободном пуле тот же readFile отвечал за считаные миллисекунды, так что порог разделяет случаи с запасом. Отсюда привычка писать require в начале файла. При старте главный поток ещё не обслуживает запросы, а подключение внутри обработчика в первый раз читает диск за счёт всех клиентов, которые ждут рядом. Все следующие вызовы спасает кеш.
requireв Node — обычная функция, которая получает строку в момент вызова. Сборщик разрешает пути до запуска, а Node ищет файл сам при первом вызове, поэтому аргумент можно даже вычислить.- Относительный путь считается от вызывающего файла: файл как написан, с
.js, с.json, потом папка сindex.js. Голое имя — пакет:node_modulesрядом с файлом, потом в каждой папке выше до корня. - Тело модуля выполняется функцией с пятью параметрами:
exports,require,module,__filename,__dirname. Поэтому у каждого модуля свойrequireи свои пути, а в ES-модуле без обёртки этих имён нет. - Загрузчик возвращает
module.exportsпосле выполнения тела.exports— только начальная ссылка на тот же объект:exports.x = …работает,exports = …меняет локальную переменную и не экспортирует ничего. - Кеш ведётся по разрешённому пути:
"./counter.js"и"../counter"из разных папок — одно выполнение и одно состояние. Удаление изrequire.cacheвыполнит тело снова, но старые ссылки останутся на прежнем объекте. requireсинхронный: файлы читает главный поток, не дожидаясь пула. Файловые функции считают относительный путь от текущей папки процесса, поэтому соседний с модулем файл читают через__dirname.
1/4В папке лежат config.js и папка config с index.js. Что вернёт require("./config") из соседнего файла?
2/4В модуле сначала написано exports.add = …, а ниже module.exports = { sub }. Что получит тот, кто его подключит?
3/4lib/a.js подключает require("./math"), а routes/b.js — require("../lib/math.js"). Сколько раз выполнится тело math.js?
4/4Почему в файле .mjs нет __dirname?
Пакет с полем main
Пакет dayjs объявляет свой вход полем main: в его package.json записано "main": "dayjs.min.js". В модели index.js у него нет, и резолвер из статьи такой пакет не находит. Научите его: у папки с package.json сначала пробуется main — как файл, потом как папка с index, — и только если там пусто, index.js самой папки. У old-lib в main записан файл, которого нет; такой пакет должен и дальше находиться через свой index.js. Относительные пути в задаче не нужны, резолвер ищет только пакеты.
Проверка расщепляется на три шага — файл, папка с main, index самой папки:
Вывод:
Порядок тот же, что в псевдокоде Node: main пробуется как файл, затем как папка с index, и только потом index самой папки пакета. У old-lib первые два шага ничего не нашли, и сработал третий. Настоящий Node в этом месте тоже находит index.js, но печатает предупреждение DEP0128: поле main указывает в пустоту, и Node просит автора пакета его исправить.
У современных пакетов поверх main стоит поле exports: если оно есть, Node смотрит в него, и файлы, которые там не перечислены, снаружи не подключаются вовсе.
Шаблон письма рядом с модулем
Модуль mail/send.js читает шаблон welcome.txt из той же папки. Разработчик проверял его, запуская процесс из /app/mail, и всё работало, а на сервере процесс стартует из /app. readFileSync в подложке ведёт себя как fs.readFileSync в Node: относительный путь считает от текущей папки процесса, а не от модуля. Поправьте одну строку в send.js, чтобы письмо собиралось, откуда бы ни запустили процесс.
Путь к шаблону строится от папки модуля:
Вывод:
require("./x") и readFileSync("x") выглядят одинаково, но считают от разного. resolve получает путь вызывающего файла, потому что у каждого модуля свой require. Файловые функции о модулях не знают ничего и берут текущую папку процесса — ту, из которой его запустили. А __dirname хранит папку самого модуля, поэтому путь к соседнему файлу строят от него.
В настоящем Node эта строка пишется через path.join(__dirname, "welcome.txt"): join сам поставит разделитель, в том числе на Windows. В ES-модуле то же самое — path.join(import.meta.dirname, "welcome.txt").
Цепочка в сообщении об ошибке
Когда пакета нет, Node дописывает к ошибке Require stack — файлы от того, кто просил, до главного. Загрузчик из статьи знает только путь текущего модуля, и по его сообщению не понять, чей require сломался. Добавьте цепочку: пусть ошибка заканчивается строкой Require stack: и путями по одному на строку, каждый после - , от /app/lib/index.js до /app/main.js.
Модуль запоминает родителя — того, кто его загрузил, — а require при неудачном поиске проходит по родителям вверх:
Вывод:
Цепочка начинается с модуля, чей require не нашёл пакет, и идёт через родителей к главному файлу. resolve обёрнут в try отдельно от load: ошибка, прилетевшая из глубины, уже несёт свою цепочку, и без этого разделения каждый уровень дописывал бы к ней ещё одну. Настоящий Node устроен так же: у каждого модуля хранится первый, кто его подключил, и requireStack собирается проходом по этим ссылкам.