Menu
Русский

CommonJS vs ES Modules: require и import в Node.js

Две системы модулей в JavaScript: зачем нужны обе, чем отличаются require и import и что выбирать в проектах на Node.js.

Две модульные системы в одном языке

Изначально в JavaScript никаких модулей не было вообще. В 2009 году Node закрыл эту дыру системой CommonJS (require, module.exports), и долгое время код на Node выглядел именно так. А в 2015 году в сам язык завезли стандартные ES Modulesimport и export, — которые сегодня поддерживают и браузеры, и Node.

Поэтому в реальных проектах вы встретите оба варианта. Вот один и тот же крошечный модуль, записанный двумя способами:

index.js
Output
Click Run to see the output here.
index.js
Output
Click Run to see the output here.

Одна и та же функция — просто в разной обёртке. Дальше поговорим о том, когда какая обёртка важнее и что выбирать под конкретную задачу.

Чем отличается синтаксис

Повседневная разница умещается буквально на открытке:

// 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, вычислить путь на лету, подгрузить модуль по условию:

index.js
Output
Click Run to see the output here.

ES Modules — статические по своей природе. Движок сначала разбирает все инструкции import, выстраивает граф зависимостей и разрешает его целиком ещё до того, как выполнится хоть одна строчка кода. Именно поэтому путь обязан быть строковым литералом, а сам import допустим только на верхнем уровне модуля.

Зато это даёт мощный бонус: инструменты видят весь граф модулей, не запуская ни одной строки. Так работает tree-shaking в сборщиках (выкидывание неиспользуемых экспортов), так редакторы дают точный автокомплит, и так браузер умеет подгружать модули параллельно.

Если же динамическая подгрузка в ESM реально нужна — на помощь приходит динамический import(). Это выражение, похожее на функцию, которое возвращает Promise:

index.js
Output
Click Run to see the output here.

Как 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-экспортом:

index.js
Output
Click Run to see the output here.
// 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, возвращая промис:

index.js
Output
Click Run to see the output here.

В современном Node.js (22+) появился синхронный require() для ESM при определённых условиях, но универсальным решением всё же остаётся динамический import().

Другие отличия в поведении, о которых стоит знать

Помимо синтаксиса, у двух систем есть ещё несколько расхождений, на которые периодически наступаешь:

index.js
Output
Click Run to see the output here.

Ещё пара нюансов:

  • this на верхнем уровне. В CJS this — это 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.

Учитесь программировать с Coddy

НАЧАТЬ