Две модульные системы в одном языке
Изначально в JavaScript никаких модулей не было вообще. В 2009 году Node закрыл эту дыру системой CommonJS (require, module.exports), и долгое время код на Node выглядел именно так. А в 2015 году в сам язык завезли стандартные ES Modules — import и export, — которые сегодня поддерживают и браузеры, и Node.
Поэтому в реальных проектах вы встретите оба варианта. Вот один и тот же крошечный модуль, записанный двумя способами:
Одна и та же функция — просто в разной обёртке. Дальше поговорим о том, когда какая обёртка важнее и что выбирать под конкретную задачу.
Чем отличается синтаксис
Повседневная разница умещается буквально на открытке:
// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");
module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";
export default something;
export const name = value;
export { name };
require — это обычный вызов функции. А import — это инструкция (statement): она может появляться только на верхнем уровне модуля, и путь обязан быть строковым литералом. Это ограничение не с потолка взято — именно оно даёт ESM возможности, которых нет у CommonJS.
В чём реальная разница: статика против динамики
В CommonJS require() выполняется в тот момент, когда до строчки доходит исполнение. Его можно засунуть внутрь if, вычислить путь на лету, подгрузить модуль по условию:
ES Modules — статические по своей природе. Движок сначала разбирает все инструкции import, выстраивает граф зависимостей и разрешает его целиком ещё до того, как выполнится хоть одна строчка кода. Именно поэтому путь обязан быть строковым литералом, а сам import допустим только на верхнем уровне модуля.
Зато это даёт мощный бонус: инструменты видят весь граф модулей, не запуская ни одной строки. Так работает tree-shaking в сборщиках (выкидывание неиспользуемых экспортов), так редакторы дают точный автокомплит, и так браузер умеет подгружать модули параллельно.
Если же динамическая подгрузка в ESM реально нужна — на помощь приходит динамический import(). Это выражение, похожее на функцию, которое возвращает Promise:
Как Node определяет, какая система используется в файле
Один и тот же Node-проект может содержать файлы обеих систем. Чтобы понять, по каким правилам обрабатывать конкретный файл, Node смотрит на две вещи:
- Расширение файла:
.mjs— это всегда ESM,.cjs— всегда CommonJS. - Поле
"type"в ближайшемpackage.json: значение"module"превращает все.js-файлы в ESM, а"commonjs"(вариант по умолчанию, если поле не указано) — в CommonJS.
// package.json
{
"name": "my-app",
"type": "module"
}
С "type": "module" обычный файл hello.js в том же пакете работает через import/export. А если рядом положить hello.cjs, то именно этот файл будет использовать require. Так проект может постепенно переезжать на новую систему, или библиотека — поставляться сразу в двух вариантах.
Типичная ловушка новичка: в ESM-файле require и module.exports попросту не существуют. Если по старой привычке попытаетесь их использовать — получите ReferenceError.
Совместимость: как смешивать CommonJS и ES Modules
Часто бывает нужно подключить CommonJS-пакет из ESM-файла или наоборот. Правила здесь несимметричные.
ESM импортирует CommonJS — работает напрямую. Объект module.exports из CJS становится default-экспортом:
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));
Именованный импорт из CommonJS иногда срабатывает — Node пытается определить именованные экспорты статически, — но для надёжности лучше взять default и разобрать его через деструктуризацию:
import pkg from "./utils.cjs";
const { parse, stringify } = pkg;
CommonJS, импортирующий ESM — вот где начинается боль. Сделать require() для ES-модуля не выйдет: получите ERR_REQUIRE_ESM. Спасает динамический import() — он работает и в CJS, возвращая промис:
В современном Node.js (22+) появился синхронный require() для ESM при определённых условиях, но универсальным решением всё же остаётся динамический import().
Другие отличия в поведении, о которых стоит знать
Помимо синтаксиса, у двух систем есть ещё несколько расхождений, на которые периодически наступаешь:
Ещё пара нюансов:
thisна верхнем уровне. В CJSthis— этоmodule.exports. В ESM —undefined. К тому же ESM всегда работает в strict mode.__dirnameи__filename. В CommonJS они доступны из коробки. В ES-модулях их придётся получать черезimport.meta.url:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Расширения файлов в импортах. В ESM для относительных путей расширение обязательно (
"./utils.js", а не"./utils"). CommonJS в этом плане снисходительнее. - Живые ссылки vs снимки. Импорты в ESM — это живые ссылки на переменные экспортирующего модуля. CJS же отдаёт копию того, что было присвоено
module.exportsна момент загрузки. В большинстве случаев разницы не видно, но при циклических зависимостях это играет роль.
Что выбрать: CommonJS или ES Modules?
Для нового проекта — однозначно ES Modules. Пропишите "type": "module" в package.json и забудьте про CJS. ESM — это стандарт языка, он одинаково работает в браузере и в Node, поддерживает top-level await, и современный тулинг изначально затачивается под него.
CommonJS имеет смысл оставить, если:
- вы поддерживаете существующую кодовую базу на CJS, и миграция пока не окупается;
- вы публикуете библиотеку, которой нужно работать на очень старых версиях Node или у потребителей, не умеющих в ESM;
- у вас есть критичная зависимость, которая поставляется только в CJS и у которой плохо с interop. (Сейчас это редкость, но бывает.)
Но даже в этом случае читать ESM-код вам придётся постоянно — всё, что публикуется в npm последние несколько лет, идёт именно туда. Так что свободно ориентироваться в обоих форматах — не роскошь, а необходимость; свободно писать — как минимум в том, на котором пишете прямо сейчас.
Короткий чек-лист в голове
Открывая новый файл, задайте себе пару вопросов:
- Что в этом файле —
import/exportилиrequire/module.exports? Смешивать нельзя. - Что написано про
"type"в ближайшемpackage.json? - Если подключаете пакет — загляните в его
package.json: что он отдаёт, ESM, CJS или оба формата? - Поймали
ERR_REQUIRE_ESM? Значит, вы в CJS пытаетесь загрузить ESM. Переходите на динамическийimport()или переводите вызывающий код на ESM.
Девяносто процентов путаницы с модулями в Node сводится к одному из этих четырёх пунктов.
Дальше: основы npm
Модули — это то, как вы раскладываете свой код по файлам. Следующий шаг — подтягивать к себе чужой код, и для этого существует npm. Разберём установку пакетов, диапазоны semver и те части npm-воркфлоу, которыми вы реально пользуетесь каждый день.
Часто задаваемые вопросы
В чём разница между require и import в JavaScript?
require — это способ подключения модулей из CommonJS: синхронный, срабатывает ровно в той строке, где вызван, и возвращает то, что модуль положил в module.exports. import — синтаксис ES Modules: статический, поднимается в начало файла и анализируется ещё до выполнения кода. Плюс они по-разному трактуют this, по-разному разрешают циклические зависимости, и только в ESM работает top-level await.
Что выбрать для нового проекта на Node.js — CommonJS или ES Modules?
Берите ES Modules. Пропишите "type": "module" в package.json и пишите через import/export. ESM — официальный стандарт, работает и в браузере, и в Node.js, поддерживает top-level await. CommonJS всё ещё встречается в старых пакетах и инструментах, так что читать его придётся, даже если писать не будете.
Можно ли смешивать require и import в одном проекте?
Можно, но по правилам. Файл .mjs или пакет с "type": "module" — это ESM; .cjs или "type": "commonjs" — CJS. Из ESM можно сделать import CommonJS-модуля (тогда module.exports приедет как default-экспорт). А вот require() напрямую подключить ESM-модуль не умеет — нужен динамический import(), который возвращает Promise.