CommonJS против ES-модулей и кэш модулей

«Чем CommonJS отличается от ES-модулей?» — вопрос, на котором проверяют, работали ли вы с реальным проектом, а не только с учебными примерами.

CommonJS (require/module.exports) загружает модули синхронно во время выполнения. ES-модули (import/export) разбираются статически до выполнения, поэтому поддерживают top-level await и tree shaking.

Что проверяет интервьюер

Понимаете ли вы, что это не «два синтаксиса одного и того же». Отсюда растут практические вопросы: почему нельзя require ESM-модуль, почему import нельзя написать внутри if, откуда в проекте "type": "module" и почему модуль-синглтон вдруг создался дважды.

Таблица различий

СвойствоCommonJSES-модули
Загрузкасинхронная, в момент вызоваасинхронная, разбор до выполнения
Условный импортrequire можно вызывать где угоднотолько статически; для условного — await import()
Значения экспортакопия значения на момент requireживая привязка (live binding)
Путь к файлу__dirname, __filenameimport.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.jsa.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.

Проверьте себя
1. Сколько раз выполнится тело модуля, если require к нему вызвать пять раз из разных файлов?
AПять раз
BОдин раз, дальше отдаётся закэшированный module.exports
CПо одному разу на каждый уровень вложенности
DЗависит от значения type в package.json
2. Почему строка exports = { sum } не экспортирует функцию?
AПотому что exports доступен только для чтения
BПотому что переприсваивание рвёт связь локальной переменной с module.exports, а наружу отдаётся именно module.exports
CПотому что имя sum зарезервировано
DПотому что exports работает только в ES-модулях
3. Какая возможность есть у ES-модулей, но отсутствует в CommonJS?
AИмпорт JSON-файлов
BTop-level await
CСинхронная загрузка зависимостей
DДоступ к __dirname