Собираем require: реестр, обёртка модуля, кеш

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

Здесь будет встроен видео-разбор темы (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, прежде чем сдаться?

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

./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?

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

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?

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

Тело счётчика выполнилось один раз, и main получил тройку: первые два номера уже забрали заметки. "../counter" из /app/routes и "./counter.js" из /app разрешились в один путь, /app/counter.js, и под этим ключом в кеше лежал готовый объект со своей переменной count. На этом держится привычный приём Node: модуль, который создаёт пул соединений с базой или читает настройки, выполняется один раз, и все файлы проекта делят один пул и одни настройки — без глобальных переменных.

Кеш — обычный объект, и запись из него можно удалить. В Node это delete require.cache[путь]: к нему прибегают, когда тесту нужен модуль с чистым состоянием. Следующий require найдёт пустое место и выполнит тело заново. В подложке — файлы прошлого примера. Прогноз: какой номер выдаст свежий счётчик и какую заметку создаст notes, загруженный до удаления?

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

Свежий счётчик начал с единицы, а 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?

прогон node 26.5.0 · 15 сентября 2026
Ключи кеша идут в том порядке, в каком загрузчик заводил записи
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(", "));
node main.cjs

Тело 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, только без выполнения файла. Прогноз: кому из двоих придётся ждать?

прогон node 26.5.0 · 15 сентября 2026
Пороги 50 и 100 мс выбраны с запасом в разы в обе стороны
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 ? "да" : "нет"}`);
});
node main.cjs

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. Относительные пути в задаче не нужны, резолвер ищет только пакеты.

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

Проверка расщепляется на три шага — файл, папка с main, index самой папки:

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

Вывод:

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

Порядок тот же, что в псевдокоде 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, чтобы письмо собиралось, откуда бы ни запустили процесс.

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

Путь к шаблону строится от папки модуля:

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

Вывод:

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.

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

Модуль запоминает родителя — того, кто его загрузил, — а require при неудачном поиске проходит по родителям вверх:

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

Вывод:

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

Цепочка начинается с модуля, чей require не нашёл пакет, и идёт через родителей к главному файлу. resolve обёрнут в try отдельно от load: ошибка, прилетевшая из глубины, уже несёт свою цепочку, и без этого разделения каждый уровень дописывал бы к ней ещё одну. Настоящий Node устроен так же: у каждого модуля хранится первый, кто его подключил, и requireStack собирается проходом по этим ссылкам.