Skip to main content

CommonJS проти es модулів у Node.js

CommonJS vs ES Modules - Node.js має дві модульні системи, і вибір не тієї призводить до реальних проблем під час виконання.

Теорія

TL;DR

  • CommonJS використовує require() / module.exports; ESM використовує import / export
  • CommonJS завантажує синхронно під час виконання; ESM аналізується статично до запуску будь-якого коду
  • CJS не може зробити require() ESM-файлу напряму; ESM може імпортувати CJS (тільки default export)
  • Новий проєкт? Бери ESM. Підтримуєш старий код? CJS досі нормально працює.
  • __dirname в ESM не існує; відтворюєш через import.meta.url

Швидкий приклад

js
// CommonJS const { add } = require('./math'); // файл читається на цьому рядку console.log(add(2, 3)); // 5 // ES Modules import { add } from './math.mjs'; // розв'язується до запуску коду console.log(add(2, 3)); // 5

Різниця в синтаксисі очевидна. Менш очевидно те, коли кожен файл фактично читається. CommonJS читає файл у момент виклику require(). ESM розв'язує всі імпорти до того, як запуститься будь-який рядок твого коду.

Головна різниця

CommonJS створювався для серверного коду, де читання з диска прийнятне і модулі завантажуються по одному. ESM створювався для браузера, де порядок завантаження важливий і бандлери повинні заздалегідь знати, які саме експорти є у файлі. Node.js додав підтримку ESM пізніше, тому обидві системи співіснують. У них окремі кеші модулів: завантажити один файл один раз як CJS і один раз як ESM означає отримати два різних екземпляри модуля.

Таблиця порівняння

ХарактеристикаCommonJSES Modules
Синтаксисrequire() / module.exportsimport / export
ЗавантаженняСинхронне, під час виконанняСтатичне, до запуску
Tree-shakingНіТак
Top-level awaitНіТак
__dirnameЄЧерез import.meta.url
За замовчуваннямТак (файли .js).mjs або "type":"module"
CJS імпортує ESMТільки через await import()N/A
ESM імпортує CJSТак (тільки default export)N/A

Коли що використовувати

  • Новий пакет або застосунок: бери ESM. Отримуєш tree-shaking, top-level await і збіг із браузерним JavaScript.
  • Бібліотека для Node.js нижче 12: CommonJS досі доречний.
  • Змішана кодова база: ESM може імпортувати CJS-файли, тому мігрувати можна поступово.
  • Публікація dual-формату: вказуй і "main" (CJS), і "exports" (ESM) у package.json.

Як Node.js вирішує, яку систему використовувати

Node.js дивиться спочатку на розширення файлу, потім на package.json. Файл .cjs завжди CommonJS. Файл .mjs завжди ESM. Звичайний .js файл слідує полю "type" у найближчому package.json. Якщо поля "type" немає, за замовчуванням береться "type": "commonjs".

Окремі кеші модулів означають, що singleton-патерн поводиться по-різному залежно від системи.

__dirname в ESM

CommonJS автоматично надає __dirname і __filename. В ESM їх немає. Відтворюєш вручну:

js
import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);

Цей шаблонний код трапляється часто при міграції існуючого Node.js-коду на ESM.

Сумісність між системами

js
// ESM імпортує CJS - працює, але тільки default export import mathModule from './math.cjs'; // CJS намагається require() ESM-файл - кидає помилку під час виконання const { add } = require('./math.mjs'); // Error: require() of ES Module not supported // CJS завантажує ESM через динамічний import - працює const { add } = await import('./math.mjs');

Однобічне обмеження має значення. Якщо опублікувати пакет тільки у форматі ESM, кожен CJS-споживач змушений переходити на await import() і потрапляти в async-контекст. Частина команд вважає це достатньою причиною залишитися на CJS ще якийсь час. Саме тому популярні пакети на кшталт lodash і axios досі постачають CJS або dual-формат.

Типові помилки

1. Пропущене розширення файлу в ESM-імпортах

js
// CommonJS - розширення не обов'язкове, Node підбирає сам const math = require('./math'); // ESM - розширення обов'язкове import { add } from './math'; // Error: Cannot find module import { add } from './math.js'; // Правильно

Node.js не підбирає розширення автоматично в режимі ESM. Браузери ніколи так не робили. ESM в Node.js слідує тому ж правилу.

2. Використання require() в .mjs-файлі

js
// math.mjs const fs = require('fs'); // ReferenceError: require is not defined in ES module scope

require не існує в ESM-скопі. Використовуй import, або, якщо без нього ніяк, createRequire з вбудованого пакету module.

3. Додавання "type": "module" без перейменування CJS-файлів

Якщо додати "type": "module" до package.json, кожен .js файл у тій директорії стає ESM. Будь-який файл, що досі використовує require(), одразу ламається. Спочатку перейменуй такі файли на .cjs.

4. Кругові залежності (circular dependencies) з різним таймінгом

CommonJS при circular require() повертає неповний експорт у момент циклу. ESM використовує live bindings, тому значення оновлюються, коли модуль-експортер їх нарешті встановлює. Обидва варіанти технічно працюють, але з різним таймінгом. Покладатися на кругові залежності в будь-якій системі - шлях до заплутаних багів.

Де зустрічається в реальному коді

  • React-проєкти на Vite: ESM за замовчуванням, tree-shaking з коробки
  • Express-застосунки: здебільшого CommonJS, поступова міграція через "type":"module"
  • Node.js CLI-інструменти: часто CommonJS через сумісність зі старим інструментарієм
  • npm-пакети у dual-форматі: поле "exports" у package.json покриває обидва варіанти

Питання на співбесіді

Q: Чи можна використовувати top-level await у CommonJS?
A: Ні. await у CommonJS працює тільки всередині async-функції. Top-level await - це виключно ESM.

Q: Що станеться, якщо два пакети завантажать один і той самий CJS-модуль?
A: CommonJS кешує модулі за шляхом файлу, тому вони отримають один і той самий екземпляр. Саме тому singleton-патерн надійно працює в CJS.

Q: Чому деякі пакети досі не підтримують ESM?
A: Публікація пакету тільки у форматі ESM ламає всіх CJS-споживачів, поки вони не оновляться під await import(). Більшість мейнтейнерів постачають dual-формат, щоб уникнути цього.

Q: Як TypeScript співпрацює з CommonJS і ESM?
A: TypeScript компілює в те, що вказано в tsconfig.json. "module": "CommonJS" дає CJS. "module": "NodeNext" дає ESM. Синтаксис TypeScript-коду виглядає як ESM в будь-якому разі.

Q: Що таке dual-format пакет?
A: Пакет, який постачає і CJS, і ESM збірки. Поле "exports" у package.json вказує Node.js, який файл брати залежно від того, чи споживач використовує require() або import:

json
{ "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }

Приклади

Базовий експорт і імпорт: обидві системи поруч

js
// --- CommonJS --- // math.js function multiply(a, b) { return a * b; } module.exports = { multiply }; // app.js const { multiply } = require('./math'); console.log(multiply(3, 4)); // 12 // --- ES Modules --- // math.mjs export function multiply(a, b) { return a * b; } // app.mjs import { multiply } from './math.mjs'; console.log(multiply(3, 4)); // 12

Обидва варіанти працюють. Різниця проявляється, коли бандлер обробляє код: ESM дозволяє прибрати multiply, якщо функція ніде не використовується. CommonJS цього не може, бо бандлер не знає заздалегідь, що знадобиться під час виконання.

Міграція файлу маршрутів Express на ESM

js
// До (CommonJS) const express = require('express'); const { getUser } = require('./services/user'); const router = express.Router(); router.get('/user/:id', async (req, res) => { const user = await getUser(req.params.id); res.json(user); }); module.exports = router; // Після (ESM) - додай "type": "module" до package.json import express from 'express'; import { getUser } from './services/user.js'; // тепер розширення обов'язкове const router = express.Router(); router.get('/user/:id', async (req, res) => { const user = await getUser(req.params.id); res.json(user); }); export default router;

Логіка ідентична. Змінюються три речі: require стає import, module.exports стає export default, і розширення файлу в шляху імпорту стає обов'язковим.

Динамічний import у CommonJS-файлі

js
// server.js (CommonJS) async function loadConfig() { // Єдиний спосіб для CJS завантажити ESM-модуль const { parseConfig } = await import('./config.mjs'); return parseConfig(process.env); } loadConfig().then(config => { console.log('Server config:', config); });

Цей патерн трапляється, коли є CJS-кодова база, але якась залежність публікується тільки у форматі ESM. Динамічний import() працює в обох системах, тому слугує мостом між ними без переписування всього коду.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?