Навигация

Модули: ESM и CJS

По мере роста приложения код перестаёт помещаться в один файл. Модули позволяют разбивать программу на изолированные части с явными зависимостями. В JavaScript существуют две основные системы модулей: ES Modules (ESM) — современный стандарт, и CommonJS (CJS) — классическая система Node.js. Понимание их различий важно для работы с фронтендом, бэкендом и сборщиками.

Содержание

  1. Зачем нужны модули
  2. ES Modules: import/export
  3. CommonJS: require/module.exports
  4. Динамический импорт import()
  5. Циклические зависимости
  6. Разница между ESM и CJS
  7. Tree shaking и side effects
  8. Ключевые тезисы для интервью
  9. Заключение
  10. Полезные ссылки

Зачем нужны модули

Без модулей переменные из разных файлов попадают в глобальную область видимости и конфликтуют друг с другом. Модуль создаёт собственное лексическое окружение: переменные верхнего уровня видны только внутри модуля, если их явно не экспортировать.

// 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 ModulesCommonJS
Синтаксисimport / exportrequire / module.exports
ВыполнениеСтатический анализ перед выполнениемВыполнение по мере вызова require
Топ-level awaitПоддерживаетсяНе поддерживается
Strict modeВсегда включёнЗависит от файла
ЗначенияСвязь live binding (live bindings)Копия значения на момент require
This в модулеundefinedmodule.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.

Полезные ссылки