CommonJS против ESM
CommonJS резолвит require() синхронно в рантайме и экспортирует копии значений; ESM — статический, поднятый, async-граф с живыми read-only биндингами и top-level await. Node выбирает систему на файл, а на стыке interop команды теряют время.
Представь кухню с двумя плитами, которые делают одно и то же — газовая и индукционная, — но требуют несовместимую посуду. Чугунная сковорода на газовой греется, переставь её на индукционную — и ничего, потому что каждая плита ждёт посуду, сделанную именно под неё. У Node ровно две такие «плиты» для загрузки твоего кода в программу — модули (файлы, на которые ты разбиваешь код) работают внутри рантайма (движка, который их и исполняет), — и большая часть дня, потерянного на настройку Node, — это просто файл, собранный под одну систему, который скармливают другой. Вот как это выглядит. Ты добавляешь "type": "module" в package.json, чтобы получить top-level await, и следующий node server.js падает с ReferenceError: require is not defined. Ты меняешь виноватый require() на import — и теперь зависимость взрывается с Error [ERR_REQUIRE_ESM]. Затем всплывает __dirname is not defined там, где его раньше не было. Ни одно из этого — не баг в твоём коде; это трение двух модульных систем, которые резолвят в разное время, по-разному копируют значения и расходятся даже в том, как файл именует свои импорты.
Зачем это тебе как разработчику: любое Node-приложение, тест-раннер и публикуемая библиотека собраны из модулей, и в тот день, когда ты смешаешь две системы — или установишь пакет, выбравший другую, — эти ошибки прилетят именно тебе. Понимание того, какая система у файла и почему, превращает полдня гадания в правку в одну строку.
CommonJS: синхронно, резолв в рантайме, копия значений
CommonJS (CJS) — изначальная модульная система Node. Ты тянешь модуль через require() — обычную функцию, которая работает во время выполнения твоего кода: она читает целевой файл с диска, выполняет его и возвращает то, что файл присвоил в module.exports. Поскольку это обычный вызов функции, ты можешь require() условно, внутри if, или собрать путь в рантайме — резолв динамический и идёт строка за строкой.
// math.cjs
function add(a, b) { return a + b; }
let calls = 0;
module.exports = { add, get calls() { return calls; }, bump() { calls++; } };
// app.cjs
const math = require("./math.cjs"); // синхронно: блокирует, пока math.cjs не выполнится
console.log(math.add(2, 3)); // 5
const { add } = math; // копия значения — `add` это снимок биндингаВажны два свойства. Первое: require() синхронен и блокирующий — вызывающая строка не продолжается, пока требуемый файл не отработает полностью; поэтому старт CJS — это обход дерева зависимостей в глубину. Второе: ты получаешь обратно копию значения на момент require. Если ты деструктурируешь const { add } = require(...), ты захватил ссылку, существовавшую тогда; если модуль позже переприсвоит module.exports.add, твой add не изменится. CJS также бесплатно даёт каждому модулю две path-глобали — __dirname и __filename — потому что резолв локальный и в рантайме.
ESM: статический, поднятый, асинхронный, с живыми биндингами
ECMAScript Modules (ESM) — это стандарт. Ты объявляешь зависимости через import/export, которые статичны: они обязаны быть на верхнем уровне (не внутри if и не в функции), чтобы движок нашёл каждый импорт до того, как выполнится хоть строка кода. Этот предварительный анализ — и есть весь смысл: он строит полный граф модулей, поднимает импорты и позволяет бандлерам точно видеть, какие экспорты используются, чтобы выбросить остальное (tree-shaking). Загрузка графа асинхронна — это и разблокирует top-level await.
// math.mjs
let calls = 0;
export function add(a, b) { return a + b; }
export function bump() { calls++; }
export function getCalls() { return calls; }
// app.mjs
import { add, bump, getCalls } from "./math.mjs"; // поднят, резолвится до запуска
import { readFile } from "node:fs/promises";
const config = await readFile("config.json", "utf8"); // top-level await — без async-обёртки
console.log(add(2, 3)); // 5
bump();
console.log(getCalls()); // 1 — живой биндинг отражает изменениеИмпортируемые экспорты — это живые биндинги, а не копии: import { add } — это read-only вид на переменную экспортирующего модуля. Если тот модуль мутирует значение за именем, твой импорт видит новое значение — а сам ты переприсвоить импортированное имя не можешь (add = ... это TypeError). Нет __dirname; вместо этого пути выводят из import.meta.url (например, new URL("./data.json", import.meta.url)).
| Свойство | CommonJS | ESM |
|---|---|---|
| Синтаксис | require() / module.exports | import / export |
| Время загрузки | Синхронно, блокирующе | Асинхронный граф |
| Резолв | Динамический, в рантайме | Статический, до выполнения |
| Семантика импорта | Копия значения на момент require | Живой read-only биндинг |
| Path-глобали | __dirname, __filename | import.meta.url |
| Top-level await | Нет | Да |
| Tree-shakeable | Нет (динамическая форма) | Да (статическая форма) |
▸Почему это работает
Почему «статичность» покупает tree-shaking? Потому что import { add } from "./math.mjs" называет ровно тот биндинг, который ты используешь, и бандлер может доказать, что bump нигде не упомянут, и удалить его из вывода — ещё до запуска строки. Экспорты CJS — это обычный объект, собранный в рантайме (module.exports.add = ...); инструмент не может узнать, какие ключи выживут, не выполнив программу, поэтому обязан оставить их все. Статическая структура — не вопрос стиля, а то, что делает граф зависимостей анализируемым.
Как Node решает, какая система у файла
Node не угадывает по содержимому. Он классифицирует каждый файл как CJS или ESM по явному набору сигналов в порядке приоритета:
- Расширение перекрывает всё.
.mjs— всегда ESM;.cjs— всегда CJS. Они побеждают остальное и являются однозначным запасным выходом. - Поле
"type"в ближайшемpackage.json. Для обычного.js-файла Node идёт вверх к ближайшемуpackage.json:"type": "module"делает.js-файлы ESM,"type": "commonjs"(или отсутствие поля) делает их CJS. Переключение этого одного поля переинтерпретирует каждый.js-файл в пакете — именно поэтомуrequire is not definedпоявляется в тот момент, когда ты его добавляешь. - Поле
"exports"управляет тем, во что резолвятсяimport "pkg"иrequire("pkg"), и через условные exports может отдавать разный файл каждому — паттерн dual-package.
{
"name": "mylib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
}
}Карта "exports" — это больше, чем двойная публикация: это граница публичного API твоего пакета. Всё, чего нет в exports, недостижимо снаружи — потребители больше не могут глубоко импортировать mylib/src/internal.js, даже если файл существует. Эта инкапсуляция — сеньорская причина определять exports осознанно, а не давать утечь всей директории.
Стык interop — где утекает время
Две системы не композируются симметрично, и эта асимметрия — настоящая боль эпохи миграции:
- ESM может импортировать CJS.
import express from "express"работает; объектmodule.exportsиз CJS становитсяdefault-экспортом ESM. Именованные импорты из CJS-модуля — best-effort: Node статически анализирует исходник CJS, чтобы угадать именованные экспорты, но всё, присвоенное динамически (module.exports[name] = ...), невидимо, поэтомуimport { Router } from "express"может упасть там, гдеimport express from "express"; const { Router } = express;работает. - CJS не может синхронно
require()ESM-модуль — исторически это кидалоERR_REQUIRE_ESM, потому что загрузка ESM асинхронна, аrequireсинхронен. Портативный фикс — dynamicimport(), который возвращает промис и работает из любой системы:const mod = await import("./esm-only.mjs"). (Свежие версии Node умеютrequire()синхронный ESM-граф, но dynamicimport()— правило, которое держится всегда.)
// CJS-файл, которому нужна ESM-only зависимость
async function main() {
const { default: chalk } = await import("chalk"); // chalk теперь ESM-only
console.log(chalk.green("работает из CJS через dynamic import()"));
}
main();CommonJS-файлу нужна зависимость, поставляемая как ESM-only. require('the-pkg') кидает ERR_REQUIRE_ESM. Какой портативный фикс?
Модуль инкрементирует внутренний счётчик и отдаёт его. Потребитель читает счётчик после инкремента. Когда потребитель увидит новое значение?
Ты начинаешь новую Node-библиотеку в 2026, которую другие будут устанавливать. Какую модульную стратегию ты публикуешь?
- 01Почему ESM-импорты tree-shakeable, а экспорты CommonJS — нет, и при чём тут «статичность»?
- 02Пройди правила interop CJS↔ESM и то, как Node решает, какая система у файла.
CommonJS и ESM — две модульные системы, различающиеся временем, семантикой и структурой. CommonJS использует require()/module.exports: резолв динамический и синхронный, вызов блокирует, пока целевой файл не выполнится, деструктурированные импорты — копии значения, замороженные на момент require, и каждый модуль бесплатно получает __dirname/__filename. ESM использует инструкции import/export, которые статичны и подняты, поэтому движок строит весь граф до выполнения — что включает tree-shaking, асинхронный граф, top-level await и живые read-only биндинги, отслеживающие текущее значение экспортёра; пути берут из import.meta.url вместо __dirname. Node решает систему файла в порядке приоритета: расширение .mjs/.cjs перекрывает всё, затем ближайшее поле “type” в package.json управляет .js-файлами, затем поле exports (с условными import/require) может отдавать разные файлы каждому и задаёт границу публичного API пакета. Стык interop асимметричен: ESM может импортировать CJS-модуль (его module.exports становится default; именованные импорты best-effort), но CJS не может синхронно require ESM — тянись к dynamic import(), который возвращает промис и работает с любой стороны. Для нового кода выбирай ESM и относись к полю exports как к осознанной поверхности API, а не как к запоздалой мысли.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.