ESM против CommonJS: import и require в Node.js

Содержание

О проверке кода. Примеры не запускались — в отличие от материалов с бейджем. Код дан по документации Node.js. Говорим прямо, а не ставим бейдж «проверено».

Два способа подключать модули в Node — источник постоянной путаницы: одни примеры на require, другие на import, и непонятно, что своё. Разберёмся, чем они отличаются и что выбрать.

Два формата

// CommonJS (CJS) — наследие
const fs = require('node:fs');
module.exports = { helper };

// ES Modules (ESM) — стандарт
import fs from 'node:fs';
export { helper };

CommonJS — родной формат Node с самого начала. ESM — официальный стандарт JavaScript, тот самый import/export из ECMAScript 6, который в Node заработал полноценно намного позже, чем появился в спецификации.

Как Node решает, какой это формат

Node смотрит на два признака:

Признак Формат
"type": "module" в package.json ESM для всех .js
"type": "commonjs" или нет поля CommonJS для .js
расширение .mjs всегда ESM
расширение .cjs всегда CommonJS
{
  "type": "module"
}

Эта строка в package.json переключает весь проект на ESM. Без неё Node трактует .js как CommonJS, и import в файле даст ошибку. Половина вопросов «почему не работает import» решается добавлением "type": "module".

Чем ESM лучше

ESM против CommonJS

ESM (import/export)

Стандарт, выбор для нового кода

  • Тот же синтаксис в браузере
  • Статический анализ → tree-shaking
  • top-level await
  • Асинхронная загрузка
  • import.meta.dirname

CommonJS (require)

Наследие, но живое

  • В миллионах старых пакетов
  • Синхронный require
  • Динамический — грузит по условию легко
  • __dirname из коробки
  • Новое на нём не пишут

Три реальных преимущества ESM:

Один синтаксис везде. В браузере, в Node, в сборщиках — везде import. CommonJS браузер не понимает вовсе.

Статический анализ. import обрабатывается до выполнения, поэтому сборщики видят, что реально используется, и выкидывают лишнее (tree-shaking). require динамический — так не проанализируешь.

Top-level await — можно писать await прямо на верхнем уровне модуля:

// ESM — работает
const config = await loadConfig();
const db = await connect(config);

// CJS — так нельзя, нужна async-обёртка

Это заметно упрощает инициализацию: подключение к базе, чтение конфига — всё пишется линейно.

__dirname и пути

Главная практическая разница при переходе. В CommonJS были глобальные __dirname и __filename; в ESM их нет:

// CommonJS
const path = require('node:path');
const file = path.join(__dirname, 'data.json');

// ESM — эквивалент
import { join } from 'node:path';
const file = join(import.meta.dirname, 'data.json');   // Node 21.2+

import.meta.dirname и import.meta.filename (с Node 21.2) заменяют старые переменные. Раньше их воссоздавали через fileURLToPath(import.meta.url) — этот код до сих пор встречается. Подробнее про пути — в разборе модуля path.

Совместимость двух миров

Реальность в том, что оба формата ещё долго будут соседствовать, и Node умеет их мостить:

// импортировать CommonJS-пакет из ESM — работает
import express from 'express';   // express внутри CommonJS, но импортируется

// нужен require внутри ESM — создать его
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const data = require('./legacy.cjs');

Из ESM можно импортировать CommonJS-модуль — это работает давно и покрывает большинство npm-пакетов. Обратное (импорт ESM в старый CommonJS) долго было невозможно синхронно; в свежих версиях Node появилась ограниченная поддержка require() для ESM.

Практический вывод: пишите новый код на ESM, а старые CommonJS-зависимости он подхватит без проблем.

Именованный и дефолтный экспорт

Короткая шпаргалка, потому что здесь путаются:

// именованные экспорты — импортируются в фигурных скобках
export function a() {}
export const b = 1;
import { a, b } from './module.js';

// дефолтный экспорт — один на модуль, имя выбираете сами
export default function () {}
import whatever from './module.js';

В своих пакетах точку входа задают через поле exports в package.json — оно же управляет тем, что видно снаружи. И помните: в ESM в пути импорта обязательно расширениеimport './utils.js', а не import './utils'.


Смежные темы: ECMAScript 6 — откуда взялись модули; свой модуль и npm — поле exports; модуль path — про import.meta.dirname. Полный список — в справочнике по Node.js.

Частые вопросы

Что выбрать — import или require?
Для нового кода — import (ESM): это стандарт языка, он же работает в браузере, у него статический анализ и top-level await. require (CommonJS) — наследие, живёт в старых проектах и продолжит работать, но новое на нём не пишут.
Как включить ESM в проекте?
Добавить “type”: “module” в package.json — тогда все .js трактуются как ESM. Либо давать модулям расширение .mjs. Без этого Node считает .js файлы CommonJS.
Можно ли использовать require в ESM?
Напрямую нет, но с недавних версий Node можно импортировать CommonJS-модуль через import, а внутри ESM создать require через createRequire. Обратно ESM в старый CommonJS долго не импортировался, сейчас с ограничениями можно.
Почему в ESM нет __dirname?
Потому что __dirname — часть CommonJS. В ESM его заменяет import.meta.dirname (с Node 21.2) и import.meta.url. Пути к соседним файлам стройте от них.
Что такое top-level await?
Возможность писать await прямо на верхнем уровне модуля, без обёртки в async-функцию. Работает только в ESM — это одно из его практических преимуществ перед CommonJS.