Модули: ESM и CJS
По мере роста приложения код перестаёт помещаться в один файл. Модули позволяют разбивать программу на изолированные части с явными зависимостями. В JavaScript существуют две основные системы модулей: ES Modules (ESM) — современный стандарт, и CommonJS (CJS) — классическая система Node.js. Понимание их различий важно для работы с фронтендом, бэкендом и сборщиками.
Содержание
- Зачем нужны модули
- ES Modules: import/export
- CommonJS: require/module.exports
- Динамический импорт
import() - Циклические зависимости
- Разница между ESM и CJS
- Tree shaking и side effects
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Зачем нужны модули
Без модулей переменные из разных файлов попадают в глобальную область видимости и конфликтуют друг с другом. Модуль создаёт собственное лексическое окружение: переменные верхнего уровня видны только внутри модуля, если их явно не экспортировать.
// utils.js
const secret = 'hidden'; // не экспортировано — доступно только внутри модуля
export const name = 'utils';
// main.js
import { name } from './utils.js';
console.log(name); // utils
console.log(secret); // ReferenceError: secret is not defined
ES Modules: import/export
ES Modules — стандарт, введённый в ES2015. Он используется в браузерах (через <script type="module">) и в Node.js (для файлов .mjs или при "type": "module" в package.json).
Именованные экспорты
// math.js
export const PI = 3.14159;
export function sum(a, b) {
return a + b;
}
export class Calculator {
// ...
}
// main.js
import { PI, sum } from './math.js';
console.log(PI); // 3.14159
console.log(sum(2, 3)); // 5
Дефолтный экспорт
Один модуль может иметь только один default-экспорт.
// logger.js
export default function log(message) {
console.log(`[LOG] ${message}`);
}
// main.js
import log from './logger.js';
log('Hello'); // [LOG] Hello
Смешанный экспорт
// api.js
export const BASE_URL = '/api';
export default function fetchUser(id) {
return fetch(`${BASE_URL}/users/${id}`);
}
import fetchUser, { BASE_URL } from './api.js';
Переименование
import { sum as add } from './math.js';
import * as math from './math.js';
console.log(add(1, 2));
console.log(math.PI);
Re-export
// index.js
export { sum, PI } from './math.js';
export { default as logger } from './logger.js';
CommonJS: require/module.exports
CommonJS — система модулей Node.js. Она появилась раньше ES Modules и до сих пор широко используется.
// math.js
const PI = 3.14159;
function sum(a, b) {
return a + b;
}
module.exports = { PI, sum };
// main.js
const { PI, sum } = require('./math.js');
console.log(PI);
console.log(sum(2, 3));
Эквивалентные формы
module.exports.foo = 1;
exports.bar = 2;
// Но перезапись exports не работает:
exports = { foo: 1 }; // module.exports не изменится
exports — это ссылка на module.exports. Если присвоить в exports новый объект, ссылка сломается.
Динамический импорт import()
Динамический импорт возвращает Promise и позволяет загружать модули по условию или лениво.
if (user.isAdmin) {
const { adminPanel } = await import('./admin.js');
adminPanel.init();
}
// Ленивый роутинг в React
const LazyComponent = React.lazy(() => import('./HeavyComponent.jsx'));
Динамический импорт работает и в ESM, и в CJS-окружении (хотя в чистом CJS обычно используют require для динамики).
Циклические зависимости
Циклическая зависимость возникает, когда модуль A импортирует модуль B, а B импортирует A.
// a.js
import { b } from './b.js';
export const a = 'a';
console.log(b);
// b.js
import { a } from './a.js';
export const b = 'b';
console.log(a);
Поведение в ESM
При циклическом импорте ESM возвращает частично инициализированный модуль. В момент выполнения b.js модуль a.js ещё не завершил инициализацию, поэтому a будет undefined.
Поведение в CJS
CJS кэширует module.exports. Если модуль ещё не завершился, require вернёт текущее состояние module.exports — обычно пустой объект {}.
Как бороться
- Разбивать модули так, чтобы общий код вынести в третий модуль.
- Использовать динамический импорт внутри функции, а не на верхнем уровне.
- Пересматривать архитектуру: циклические зависимости часто сигнализируют о смешении ответственности.
// вместо импорта на верхнем уровне
function init() {
const { a } = require('./a.js'); // динамически, когда нужно
return a;
}
Разница между ESM и CJS
| Особенность | ES Modules | CommonJS |
|---|---|---|
| Синтаксис | import / export | require / module.exports |
| Выполнение | Статический анализ перед выполнением | Выполнение по мере вызова require |
| Топ-level await | Поддерживается | Не поддерживается |
| Strict mode | Всегда включён | Зависит от файла |
| Значения | Связь live binding (live bindings) | Копия значения на момент require |
| This в модуле | undefined | module.exports |
| Использование в браузере | Нативно через <script type="module"> | Требует сборщика |
Live bindings
В ESM импортированные переменные связаны с экспортом. Если экспорт изменится, импорт тоже изменится.
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1
В CJS так не работает: require копирует значение.
// counter.js
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0 — копия не изменилась
Чтобы получить актуальное значение в CJS, экспортируйте getter:
module.exports = {
get count() { return count; },
increment,
};
Статический vs динамический
ESM анализирует зависимости до выполнения кода. Это позволяет сборщикам делать tree shaking — удалять неиспользуемый код.
CJS выполняется динамически: require можно вызывать внутри if, цикла или функции. Поэтому сборщикам сложнее определить, что реально используется.
Tree shaking и side effects
Tree shaking — удаление неиспользуемого кода при сборке. Оно работает лучше с ESM, потому что import/export статические.
// utils.js
export function used() { /* ... */ }
export function unused() { /* ... */ }
// main.js
import { used } from './utils.js';
used();
Сборщик может удалить unused, если уверен, что её вызов не даёт side effects — побочных эффектов, таких как изменение глобального состояния, регистрация полифилов или инициализация библиотеки.
Side effects
// polyfill.js
if (!Array.prototype.toSorted) {
Array.prototype.toSorted = function () { /* ... */ };
}
Такой файл нельзя удалять, даже если из него ничего не импортируется. В package.json библиотек указывают:
{
"sideEffects": ["./src/polyfill.js", "*.css"]
}
Если sideEffects: false, сборщик считает, что любой неиспользуемый экспорт можно удалить.
Ключевые тезисы для интервью
- ES Modules (ESM) — современный стандарт с
import/export, CommonJS (CJS) — классическая система Node.js сrequire/module.exports. - Именованные экспорты позволяют экспортировать несколько значений, дефолтный экспорт — один на модуль.
- В CJS
exports— это ссылка наmodule.exports; перезаписьexportsломает экспорт. - Динамический импорт
import()возвращает Promise и позволяет загружать модули по условию или лениво. - Циклические зависимости возникают при взаимном импорте модулей; их устраняют выносом общего кода или динамическим импортом.
- В ESM работают live bindings — импортированные переменные синхронизируются с экспортом, в CJS значения копируются на момент
require. - Tree shaking удаляет неиспользуемый код при сборке и эффективнее работает со статическими ESM-импортами.
- ESM предпочтителен для новых проектов благодаря статическому анализу и эффективному tree shaking; CJS остаётся для поддержки существующего кода в Node.js.
Заключение
Модули — основа структуры любого JavaScript-приложения. ESM и CJS решают одну задачу, но делают это по-разному: ESM опирается на статический анализ и live bindings, а CJS — на динамическое выполнение и копирование значений. Понимание их различий помогает правильно организовать код, избежать циклических зависимостей и эффективно использовать tree shaking.