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 (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.