Модульная система: CJS, ESM и свой loader
Когда ты пишешь import x from './lib.js', Node делает несколько вещей
подряд: разрешает путь, читает файл, исполняет его в изолированном
скоупе, кэширует и отдаёт exports. Если знать, что именно происходит,
многие странности экосистемы (dual-package hazard, зависимости с CJS и
ESM, циклы) перестают казаться магией.
В уроке два потока: разбор готовых систем (CommonJS и ESM) и самостоятельная сборка мини-loader-а в духе metarhia/NodeJS-Pure, чтобы увидеть устройство изнутри.
1. Каждый файл, своя обёртка
Node не запускает твой module.js как есть. Перед исполнением файл
оборачивается в функцию (для CJS):
(function (exports, require, module, __filename, __dirname) {
// ... твой код ...
});
Эту обёртку Node вызывает с готовыми аргументами. Поэтому в каждом
CJS-файле есть свои локальные module, exports, require,
__dirname, __filename, не глобальные. Это и есть “у каждого файла
свой scope”.
В ESM формальной обёртки нет, но эффект тот же: модуль изолирован,
переменные верхнего уровня в global не утекают. Глобальные __dirname
и __filename в ESM не предусмотрены, вместо них
import.meta.
2. CommonJS: что прячется за require
// math.js
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js
const { add } = require('./math.js');
console.log(add(2, 3));
Что Node делает на require('./math.js'):
- Разрешение пути.
'./math.js'относительно__dirnameмодуля, где вызванrequire. Получается абсолютный путь. - Кэш. Если по этому пути модуль уже загружен, возвращает
module.exportsиз кэша.require.cacheдоступен и его можно читать. - Чтение файла. Если в кэше нет, Node читает с диска.
- Обёртка. Код оборачивается в функцию из § 1.
- Запись в кэш. До исполнения тела модуль попадает в
require.cacheс пустымmodule.exports = {}. Это важно для циклов (см. § 4). - Исполнение. Функция вызывается, тело модуля выполняется синхронно.
- Возврат
module.exports.
Опасность путаницы module.exports и exports:
// плохо: exports = { add } РАЗРЫВАЕТ ссылку на module.exports
exports = { add };
// нормально: меняем поле объекта, на который ещё смотрит module.exports
exports.add = add;
// лучше всего: явно через module.exports
module.exports = { add };
exports это просто локальная переменная, которая изначально указывает
на module.exports. Присваивание exports = ... ломает связь, и Node
всё равно вернёт старый module.exports (пустой).
3. ESM: статический анализ и асинхронная загрузка
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(2, 3));
Принципиальные отличия от CJS:
- Статика.
importиexportэто объявления, не выражения. Node парсит модуль, строит граф зависимостей и только потом исполняет. Импортов внутриif/tryне бывает (для них естьimport()как выражение). - Асинхронность. Линковка модулей и загрузка происходят
асинхронно. Это позволяет top-level
await. - Live bindings. Импортируемое имя, не копия, а ссылка на
переменную в модуле-источнике. Если экспорт переприсваивается,
потребитель видит новое значение. (В CJS наоборот: ты получаешь
снапшот
module.exportsна моментrequire.)
// counter.js (ESM)
export let n = 0;
export function inc() { n += 1; }
// app.js
import { n, inc } from './counter.js';
console.log(n); // 0
inc();
console.log(n); // 1, live binding
В CJS это бы пришлось писать через геттер.
Для модуля, известного заранее, используй статический import: зависимость
попадёт в граф сборки сразу. Если спецификатор действительно выбирается в
рантайме, используй await import(specifier), который возвращает промис на
namespace-объект. specifier здесь приходит из реестра доступных модулей.
4. Циклы
Циклы случаются: a.js импортирует b.js, тот импортирует a.js. В
обеих системах это работает, но с разной семантикой.
В CJS второй вызов require('./a.js') получает текущий
module.exports модуля a в его частично инициализированном виде
(всё, что дописано к module.exports ДО точки рекурсии). Если в a ты
делаешь module.exports = ... после вызова require('./b.js'), то
b увидит пустой объект.
// a.js (CJS)
exports.before = 1;
const b = require('./b.js');
exports.after = 2; // b уже считал, увидит { before: 1 }
// b.js (CJS)
const a = require('./a.js');
console.log(a); // { before: 1 }
Лекарство: либо избегать циклов, либо ставить весь экспорт ДО первого
require зависимого модуля.
В ESM Node строит граф ещё до исполнения. На момент входа в a
импорты из b уже зарезервированы как live bindings. Если b к моменту
обращения уже не доинициализирован, читаешь undefined. Цикл в ESM
проще читается, но всё равно фрагилен.
Если в проекте часто появляются циклы, это сигнал что слои перепутаны: скорее всего, нужна третья прослойка, на которую смотрят оба.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.