Урок по треку Node.js и бэкенд 6 мин

CommonJS и ES-модули в Node.js

CommonJS и ES-модули, разрешение путей, require(esm) в Node 22, поле exports в package.json.

В Node.js живут две системы модулей: исторический CommonJS (require, module.exports) и стандартные ES-модули (import, export). На собесе проверяют, понимаешь ли ты разницу и как они уживаются вместе.

Как Node решает, какой это модуль

  • .cjs всегда CommonJS, .mjs всегда ES-модуль.
  • .js смотрит на ближайший package.json: "type": "module" значит ESM, иначе (или "type": "commonjs") CommonJS. Если поля нет, Node 22 ещё и смотрит на синтаксис: увидел import и выполнит файл как ESM. Но это запасной механизм, в проекте формат задают явно.

Встроенные модули лучше подключать с префиксом node:: import fs from 'node:fs'. Так сразу видно, что это не пакет из npm.

CommonJS

// math.cjs
function sum(a, b) { return a + b; }
module.exports = { sum };

// app.cjs
const { sum } = require('./math.cjs');
  • require синхронный: файл читается и выполняется сразу.
  • Модуль выполняется один раз, результат кешируется в require.cache. Повторный require вернёт тот же объект.
  • Внутри доступны __dirname, __filename, module, exports, require: Node оборачивает файл в функцию с этими параметрами.
  • exports это просто ссылка на module.exports. Строка exports = { a: 1 } перезапишет локальную переменную, а модуль экспортирует пустой объект.
  • При циклических зависимостях второй модуль получит недоделанный exports первого: тот, что успел заполниться к моменту require.

ES-модули

// math.mjs
export function sum(a, b) { return a + b; }
export default sum;

// app.mjs
import sum, { sum as add } from './math.mjs';
const { readFile } = await import('node:fs/promises'); // динамический импорт
  • Импорты статические: граф зависимостей строится до выполнения кода, это позволяет tree shaking и раннюю проверку имён.
  • Импорт это живая привязка: если модуль поменяет экспортированную переменную, импортёр увидит новое значение.
  • Есть top-level await.
  • Нет __dirname и require. Вместо них import.meta.dirname, import.meta.filename (в Node 22 есть) и import.meta.url.
  • В относительных путях расширение обязательно: ./math.mjs, а не ./math.
  • Код всегда в строгом режиме.

Как они смешиваются

  • ESM импортирует CJS: можно. module.exports приходит как default-экспорт; именованные импорты работают, если Node распознал их статически (например, exports.a = 1).
  • CJS загружает ESM: всегда через await import(). В Node 22.12+ работает и require() для ES-модуля, но только если в нём нет top-level await, иначе ошибка ERR_REQUIRE_ASYNC_MODULE.

package.json: main и exports

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" },
    "./utils": "./dist/utils.js"
  }
}

main задаёт точку входа по-старому. Поле exports современнее и строже: оно задаёт точки входа для import и require (conditional exports) и закрывает всё остальное. Импорт my-lib/dist/secret.js при наличии exports упадёт с ERR_PACKAGE_PATH_NOT_EXPORTED.

Частые ошибки

  • Писать exports = ... вместо module.exports = ....
  • Ждать, что require вернёт свежую копию модуля: он вернёт кеш.
  • Использовать __dirname в ESM.
  • Импортировать ./file без расширения в ESM.
  • Публиковать библиотеку, где ESM- и CJS-сборка держат разное состояние (dual package hazard): один и тот же пакет загрузится дважды.

Что спросят на собесе

  • Чем CommonJS отличается от ES-модулей.
  • Как Node понимает, что файл ESM.
  • Что произойдёт при циклическом require.
  • Можно ли подключить ESM из CJS и наоборот.
  • Зачем нужно поле exports в package.json.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.