CommonJS против ES-модулей и кэш модулей
«Чем CommonJS отличается от ES-модулей?» — вопрос, на котором проверяют, работали ли вы с реальным проектом, а не только с учебными примерами.
CommonJS (
require/module.exports) загружает модули синхронно во время выполнения. ES-модули (import/export) разбираются статически до выполнения, поэтому поддерживают top-levelawaitи tree shaking.
Что проверяет интервьюер
Понимаете ли вы, что это не «два синтаксиса одного и того же». Отсюда растут практические вопросы: почему нельзя require ESM-модуль, почему import нельзя написать внутри if, откуда в проекте "type": "module" и почему модуль-синглтон вдруг создался дважды.
Таблица различий
| Свойство | CommonJS | ES-модули |
| Загрузка | синхронная, в момент вызова | асинхронная, разбор до выполнения |
| Условный импорт | require можно вызывать где угодно | только статически; для условного — await import() |
| Значения экспорта | копия значения на момент require | живая привязка (live binding) |
| Путь к файлу | __dirname, __filename | import.meta.url, import.meta.dirname |
| Top-level await | нет | да |
| Как включить | по умолчанию, либо .cjs | "type": "module" в package.json или .mjs |
Практический вывод: ESM может импортировать CommonJS, а обратное долгое время было невозможно — из ESM в CJS попадали только через динамический await import(). В свежих версиях Node появилась поддержка require() для ESM без top-level await, но на собеседовании безопаснее назвать ограничение и упомянуть, что оно постепенно снимается.
Кэш модулей: модуль выполняется один раз
Это второй по частоте вопрос в теме. Ответ: при первом require модуль выполняется, его module.exports сохраняется в кэше по абсолютному пути, и все последующие require получают тот же самый объект. Отсюда бесплатный синглтон — пул соединений или конфиг, созданные в модуле, будут общими для всего приложения.
Механику удобно понять на модели — здесь она полностью синхронная, её можно запустить:
const cache = {};
function fakeRequire(name, factory) {
if (cache[name]) {
console.log('из кэша:', name);
return cache[name];
}
console.log('выполняем модуль:', name);
const module = { exports: {} };
factory(module);
cache[name] = module.exports;
return module.exports;
}
function counterModule(module) {
let value = 0;
module.exports = {
inc: () => ++value,
get: () => value,
};
}
const a = fakeRequire('counter', counterModule);
const b = fakeRequire('counter', counterModule);
a.inc();
a.inc();
b.inc();
console.log('a === b:', a === b);
console.log('значение счётчика:', b.get());
Вывод:
выполняем модуль: counter
из кэша: counter
a === b: true
значение счётчика: 3
Ровно так ведёт себя настоящий require. Отсюда два следствия для собеседования: во-первых, состояние в модуле — общее для всего процесса; во-вторых, кэш ключуется абсолютным путём, поэтому один и тот же пакет, установленный в двух местах node_modules, даст два независимых экземпляра со своим состоянием. Именно так «синглтон» неожиданно превращается в два. В тестах кэш иногда сбрасывают через delete require.cache[require.resolve('./config')] — приём известный, но в проде ему делать нечего.
Ловушка: exports против module.exports
// РАБОТАЕТ: дополняем объект, на который смотрит module.exports
exports.sum = (a, b) => a + b;
// НЕ РАБОТАЕТ: переприсваиваем локальную переменную, связь с module.exports разорвана
exports = { sum: (a, b) => a + b };
// РАБОТАЕТ: экспортируем функцию целиком
module.exports = function sum(a, b) { return a + b; };
Объяснение простое: exports — это просто локальная переменная, изначально указывающая на тот же объект, что и module.exports. Наружу отдаётся именно module.exports. Переприсваивание exports ломает связь, и модуль экспортирует пустой объект.
Циклические зависимости
Если a.js требует b.js, а b.js — a.js, ошибки не будет: Node вернёт частично заполненный module.exports модуля a — то, что успело выполниться до момента require. На практике это выглядит как загадочный undefined у импортированной функции. В ESM ситуация мягче за счёт живых привязок и hoisting'а функций, но обращение к значению до инициализации всё равно даст ошибку. Лечение одно и то же: вынести общий код в третий модуль.
Типичные ошибки кандидатов
- Считают
import«просто новым синтаксисом require» и не знают про статический разбор. - Не знают, что модуль выполняется один раз, и пишут в модуле инициализацию, рассчитывая на повторный запуск.
- Путают
exportsиmodule.exportsпри переприсваивании. - Не могут объяснить, откуда в ESM берётся
__dirname(его там нет).
Как ответить кратко
CommonJS загружает модули синхронно во время выполнения, ESM разбирает импорты статически до запуска кода — отсюда top-level await, живые привязки и tree shaking у ESM и возможность условного
requireу CJS. Модуль в обоих случаях выполняется один раз: результат кэшируется по абсолютному пути, поэтому модуль — это естественный синглтон, а один пакет в двух каталогахnode_modulesдаст два экземпляра. И отдельная классика: переприсваиватьexportsнельзя, наружу уходитmodule.exports.