В 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.