Skip to main content

Модулі JavaScript: import/export, CommonJS проти es модулів

Модулі JavaScript розбивають код на окремі файли, кожен з яких має свою область видимості. Є дві системи: CommonJS (require/module.exports), стандарт Node.js до появи ESM, та ES Modules (import/export), сучасний веб-стандарт.

Теорія

TL;DR

  • Аналогія: модулі як кухні в ресторані. Кожна виставляє страви через меню (exports), а інші кухні беруть що треба, не маючи доступу до сировини одна одної. Глобальний scope залишається чистим.
  • CommonJS завантажує синхронно під час виконання. ES Modules парсяться статично під час завантаження.
  • ESM підтримує tree-shaking; CommonJS ні.
  • Node 14+ підтримує обидва формати. Браузери підтримують тільки ESM нативно.
  • Правило вибору: ESM для всього нового. CommonJS тільки для legacy-кодобаз на старому Node.

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

Одні й ті самі математичні утиліти, два формати:

javascript
// CommonJS const PI = 3.14159; function add(a, b) { return a + b; } module.exports = { PI, add }; const math = require('./math.cjs'); console.log(math.add(math.PI, 1)); // 4.14159 // ES Modules export const PI = 3.14159; export function add(a, b) { return a + b; } import { PI, add } from './math.mjs'; console.log(add(PI, 1)); // 4.14159

ESM дозволяє імпортувати тільки потрібне за іменем. CommonJS повертає весь об'єкт module.exports, а ти вже деструктуруєш його сам.

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

ES Modules будують статичний граф залежностей під час парсингу. Бандлери на кшталт Webpack або Rollup читають цей граф до запуску коду, тому можуть видаляти невикористані експорти (tree-shaking). CommonJS виконує require() у рантаймі, і бандлер не може заздалегідь знати, що реально використовується. Саме ця різниця пояснює, чому перехід бібліотеки з CJS на ESM помітно зменшує розмір бандлу. ESM також підтримує top-level await; CommonJS ні, бо синхронне завантаження і async несумісні.

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

  • Браузер або Vite/Webpack: ESM. Нативна підтримка, tree-shaking, без зайвих налаштувань.
  • Node.js 14+: ESM з розширенням .mjs або "type": "module" у package.json.
  • Node.js старший за 14, або є потреба в require(variable) у рантаймі: CommonJS.
  • Змішані кодобази: динамічний import() працює і з ESM, і з CommonJS файлів.

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

ВластивістьES ModulesCommonJS
Синтаксисimport / exportrequire() / module.exports
ЗавантаженняСтатичне, під час парсингуСинхронне, під час виконання
Підтримка браузерівНативнаПотрібен бандлер
Tree-shakingТакНі
Top-level awaitТакНі
Default-експортexport default foomodule.exports = foo
Динамічні імпортиawait import(variable)require(variable) напряму
Стандарт Node.jsНі (потрібна конфігурація)Так (до v14)
Коли використовуватиReact 18+, Node 20+, новий кодLegacy Express 4.x, Node < 14

Як це обробляє рушій

V8 парсить ESM під час завантаження, будує статичний граф залежностей і прив'язує імена експортів до живих посилань (live bindings) до запуску коду модуля. Node.js спочатку перевіряє поле "type" у package.json, потім розширення файлу: .mjs примусово вмикає ESM, .cjs примусово CommonJS. CommonJS зберігає завантажені модулі у require.cache, тому повторний require('./math') повертає той самий закешований module.exports без повторного запуску файлу.

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

Забуте розширення .js у Node ESM:

javascript
import { PI } from './math'; // Error: Cannot find module import { PI } from './math.js'; // Правильно

Node ESM вимагає явного розширення. CommonJS вгадує сам. Ця помилка трапляється майже у всіх, хто мігрує з CJS.

require у ESM-файлі:

javascript
// .mjs або package.json з "type": "module" const fs = require('fs'); // SyntaxError: require is not defined in ES module scope import fs from 'node:fs'; // Правильно

Кілька default-експортів:

javascript
export default foo; export default bar; // SyntaxError: тільки один default на модуль

Якщо потрібно більше, використовуй named exports.

Експорт до визначення у CommonJS:

javascript
module.exports.add = add; // ReferenceError: add is not defined function add(a, b) { return a + b; } // спочатку оголоси module.exports = { add }; // потім експортуй

ESM сам підіймає (hoisting) оголошення функцій.

Очікувати tree-shaking від CommonJS у бандлері:

javascript
// Бандлер не видалить unusedFn у CommonJS module.exports.unusedFn = function() {}; module.exports.usedFn = function() {}; // Переходь на ESM named exports для оптимізації бандлу

Де зустрічається у продакшені

  • React 18+: import React from 'react' - tree-shaking, ESM стандарт.
  • Node 20 + Express 5: "type": "module", import express from 'express'.
  • Vite (SvelteKit, Next.js): нативний ESM, динамічний import() для code-splitting роутів.
  • Webpack 5: ESM-вивід за замовчуванням, обробляє CommonJS на вході.
  • Deno і Bun: тільки ESM, конфігурація не потрібна.

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

Q: Як працює tree-shaking в ESM, але не в CommonJS?
A: Експорти ESM - це статичні імена, відомі під час парсингу. Бандлер відстежує, які імена імпортуються, і видаляє решту. У CommonJS module.exports - звичайний об'єкт, що формується у рантаймі, тому бандлер не може без запуску визначити, що реально використовується.

Q: Що відбувається при циклічних залежностях?
A: ESM частково вирішує прив'язки до запуску коду, тому більшість циклічних імпортів працюють коректно. CommonJS може повернути порожній {} для ще не завантаженого модуля, що призводить до помилок у рантаймі, які складно відстежити.

Q: Як увімкнути ESM у Node.js?
A: Додай "type": "module" у package.json або використовуй розширення .mjs. Для явного CommonJS - .cjs.

Q: Як працює динамічний import()?
A: const mod = await import('./math.js') повертає Promise з простором імен модуля. Працює і в ESM, і в CommonJS файлах - це стандартний спосіб поєднати обидва формати в одному проєкті.

Q: Чи можна використовувати ESM у браузері без бандлера?
A: Так. <script type="module" src="./main.js"></script> підтримується у всіх сучасних браузерах. Потрібні відносні шляхи з явним розширенням (./math.js); bare-специфікатори (math) не підтримуються.

Питання рівня senior: Що саме робить V8, коли зустрічає import, і чому це дозволяє top-level await?
A: V8 створює ModuleRecord під час парсингу, асинхронно завантажує і вирішує всі залежності (будує граф), потім прив'язує кожне ім'я експорту до живого посилання до запуску будь-якого коду модуля. Оскільки вся фаза прив'язки асинхронна, рушій може призупинити виконання на await без блокування потоку. require() у CommonJS - це синхронний виклик: файл виконується одразу, повертає module.exports і виходить. Асинхронної фази немає, тому top-level await неможливий.

Приклади

Іменовані та default-експорти

javascript
// userService.js export const DEFAULT_ROLE = 'viewer'; // іменований експорт export function createUser(name, role = DEFAULT_ROLE) { return { name, role, id: Date.now() }; } export default class UserService { // default-експорт async getUser(id) { return { id, name: 'Alice', role: 'admin' }; } }
javascript
// main.js import UserService, { createUser, DEFAULT_ROLE } from './userService.js'; const service = new UserService(); const user = await service.getUser(1); // { id: 1, name: 'Alice', role: 'admin' } const newUser = createUser('Bob'); // { name: 'Bob', role: 'viewer', id: 1718000000000 }

Default і named exports можуть бути в одному файлі. Default - для головного, що надає модуль; named - для допоміжного.

Динамічні імпорти і barrel-файли

javascript
// services/index.js (barrel-файл) export { default as UserService } from './userService.js'; export { default as ProductService } from './productService.js'; export * from './utils.js'; // app.js - зрозумілі шляхи імпорту import { UserService, ProductService } from './services/index.js';

Barrel-файли спрощують шляхи імпорту по всій кодобазі. Vite і Webpack 5 правильно проводять через них tree-shaking. Старіші бандлери можуть підтягнути більше, ніж очікується, тому перевіряй розмір бандлу після додавання нових barrel-файлів.

Динамічний імпорт для завантаження за потребою:

javascript
// Важкий модуль завантажується тільки тоді, коли дійсно потрібен async function handleExport(data) { const { generatePDF } = await import('./pdfGenerator.js'); return generatePDF(data); } // React lazy loading - той самий механізм const ReportPage = React.lazy(() => import('./pages/ReportPage.js'));

Поведінка при циклічних залежностях

Це те, що відрізняє кандидатів, які реально читали специфікацію, від тих, хто знає тільки синтаксис.

javascript
// a.mjs import { fnB } from './b.mjs'; export function fnA() { return fnB(); } // b.mjs import { fnA } from './a.mjs'; export function fnB() { return 'B called'; } // main.mjs import { fnA } from './a.mjs'; console.log(fnA()); // 'B called' - ESM справляється коректно

ESM вирішує це через живі прив'язки (live bindings). До моменту, коли fnA реально викликає fnB, прив'язка вже встановлена. У CommonJS те саме ламається: require('./b') під час завантаження a повертає {}, бо b ще не завершив завантаження. Я бачив, як цей патерн давав тихі помилки undefined is not a function в Express-додатках, де два сервісні файли імпортували один одного - такі баги знаходяться годинами.

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

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

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

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