Модулі 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.
Швидкий приклад
Одні й ті самі математичні утиліти, два формати:
// 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.14159ESM дозволяє імпортувати тільки потрібне за іменем. 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 Modules | CommonJS |
|---|---|---|
| Синтаксис | import / export | require() / module.exports |
| Завантаження | Статичне, під час парсингу | Синхронне, під час виконання |
| Підтримка браузерів | Нативна | Потрібен бандлер |
| Tree-shaking | Так | Ні |
Top-level await | Так | Ні |
| Default-експорт | export default foo | module.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:
import { PI } from './math'; // Error: Cannot find module
import { PI } from './math.js'; // ПравильноNode ESM вимагає явного розширення. CommonJS вгадує сам. Ця помилка трапляється майже у всіх, хто мігрує з CJS.
require у ESM-файлі:
// .mjs або package.json з "type": "module"
const fs = require('fs'); // SyntaxError: require is not defined in ES module scope
import fs from 'node:fs'; // ПравильноКілька default-експортів:
export default foo;
export default bar; // SyntaxError: тільки один default на модульЯкщо потрібно більше, використовуй named exports.
Експорт до визначення у CommonJS:
module.exports.add = add; // ReferenceError: add is not defined
function add(a, b) { return a + b; } // спочатку оголоси
module.exports = { add }; // потім експортуйESM сам підіймає (hoisting) оголошення функцій.
Очікувати tree-shaking від CommonJS у бандлері:
// Бандлер не видалить 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-експорти
// 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' };
}
}// 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-файли
// 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-файлів.
Динамічний імпорт для завантаження за потребою:
// Важкий модуль завантажується тільки тоді, коли дійсно потрібен
async function handleExport(data) {
const { generatePDF } = await import('./pdfGenerator.js');
return generatePDF(data);
}
// React lazy loading - той самий механізм
const ReportPage = React.lazy(() => import('./pages/ReportPage.js'));Поведінка при циклічних залежностях
Це те, що відрізняє кандидатів, які реально читали специфікацію, від тих, хто знає тільки синтаксис.
// 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-додатках, де два сервісні файли імпортували один одного - такі баги знаходяться годинами.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.