open atlas
↑ К треку
Node.js с нуля до senior NODE · 01 · 01

CommonJS против ESM

CommonJS резолвит require() синхронно в рантайме и экспортирует копии значений; ESM — статический, поднятый, async-граф с живыми read-only биндингами и top-level await. Node выбирает систему на файл, а на стыке interop команды теряют время.

NODE Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Представь кухню с двумя плитами, которые делают одно и то же — газовая и индукционная, — но требуют несовместимую посуду. Чугунная сковорода на газовой греется, переставь её на индукционную — и ничего, потому что каждая плита ждёт посуду, сделанную именно под неё. У 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)).

СвойствоCommonJSESM
Синтаксисrequire() / module.exportsimport / export
Время загрузкиСинхронно, блокирующеАсинхронный граф
РезолвДинамический, в рантаймеСтатический, до выполнения
Семантика импортаКопия значения на момент requireЖивой read-only биндинг
Path-глобали__dirname, __filenameimport.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 синхронен. Портативный фикс — dynamic import(), который возвращает промис и работает из любой системы: const mod = await import("./esm-only.mjs"). (Свежие версии Node умеют require() синхронный ESM-граф, но dynamic import() — правило, которое держится всегда.)
// 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, которую другие будут устанавливать. Какую модульную стратегию ты публикуешь?

Вспомните перед уходом
  1. 01
    Почему ESM-импорты tree-shakeable, а экспорты CommonJS — нет, и при чём тут «статичность»?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.