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
Швидкий приклад
// 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 означає отримати два різних екземпляри модуля.
Таблиця порівняння
| Характеристика | CommonJS | ES Modules |
|---|---|---|
| Синтаксис | require() / module.exports | import / 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 їх немає. Відтворюєш вручну:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Цей шаблонний код трапляється часто при міграції існуючого Node.js-коду на ESM.
Сумісність між системами
// 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-імпортах
// 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-файлі
// math.mjs
const fs = require('fs'); // ReferenceError: require is not defined in ES module scoperequire не існує в 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:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}Приклади
Базовий експорт і імпорт: обидві системи поруч
// --- 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
// До (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-файлі
// 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() працює в обох системах, тому слугує мостом між ними без переписування всього коду.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.