Модули в JavaScript от А до Я: import/export, CommonJS и import()

Модули в JavaScript от А до Я: import/export, CommonJS и import() Полезное

Модуль в JavaScript — это отдельный файл со своей областью видимости: переменные внутри него не попадают в глобальную область, а наружу выходит только то, что явно экспортировано. Сегодня в JS живут две системы модулей: стандартные ES-модули (import/export, работают в браузере и Node.js) и CommonJS (require/module.exports, исторический формат Node.js). Отдельно от них есть динамический import() — функция, которая загружает модуль во время выполнения.

Ниже — как писать и подключать модули, чем ES-модули отличаются от CommonJS, как они работают в браузере и в Node.js и что делать с частыми ошибками. Все примеры для Node.js проверены на Node.js 24.21 (ветка 24 LTS) в сентябре 2026 года. Общая идея разбиения программы на независимые части разобрана в статье про модульное программирование, здесь — только JavaScript.

Мини-словарь

  • Экспорт — то, что модуль отдает наружу (функции, константы, классы).
  • Импорт — подключение экспортов другого модуля.
  • Спецификатор — строка в import ... from '...' или require('...'): относительный путь (./math.mjs), имя пакета (lodash) или встроенный модуль (node:fs).
  • ESM и CJS — сокращения для ES-модулей и CommonJS. Это разные системы с разными правилами загрузки, а не два синтаксиса одного и того же.

Минимальный пример: ES-модуль в Node.js

Два файла в одной папке. Расширение .mjs говорит Node.js, что это ES-модуль, без дополнительных настроек.

// math.mjs
export const PI = 3.14159;

export function area(r) {
  return PI * r * r;
}

export default function round2(x) {
  return Math.round(x * 100) / 100;
}
// main.mjs
import round2, { area, PI } from './math.mjs';
import * as math from './math.mjs';

console.log(PI);                       // именованный импорт
console.log(round2(area(2)));          // default + именованный
console.log(Object.keys(math).sort()); // объект-пространство имен

Запуск node main.mjs печатает:

3.14159
12.57
[ 'PI', 'area', 'default' ]

Что здесь происходит:

  • export const и export function — именованные экспорты, импортируются в фигурных скобках под тем же именем (переименовать можно через as: import { area as circleArea }).
  • export default — экспорт по умолчанию, один на модуль; при импорте имя выбирает тот, кто импортирует (round2 без скобок).
  • import * as math собирает все экспорты в один объект; default в нем лежит под ключом default.
  • Модуль выполняется один раз: второй import того же файла не запускает его код повторно, а берет уже загруженный экземпляр.

Кроме этого есть реэкспорт — export { area } from './math.mjs' или export * from './math.mjs'. Им собирают «входной» файл пакета из нескольких модулей.

Импорт — это живая связь, а не копия

Важное отличие ESM от CommonJS, которое редко объясняют. Импортированное имя в ESM ссылается на переменную модуля, поэтому видит ее изменения. Присвоить ему новое значение снаружи нельзя.

// counter.mjs
export let count = 0;
export function inc() {
  count += 1;
}
// main.mjs
import { count, inc } from './counter.mjs';

console.log(count); // 0
inc();
console.log(count); // 1 - импорт видит новое значение
try {
  count = 10;
} catch (e) {
  console.log(e.constructor.name + ': ' + e.message);
}
0
1
TypeError: Assignment to constant variable.

Тот же счетчик на CommonJS ведет себя иначе: module.exports = { count, inc } кладет в объект текущее значение count, и дальше оно не обновляется. Код const { count, inc } = require('./counter.cjs'); inc(); console.log(count); напечатает 0. Если в CommonJS нужно актуальное значение, его отдают через функцию-геттер.

CommonJS: require и module.exports

CommonJS — формат, с которым Node.js жил до появления ES-модулей. Его по-прежнему много в npm-пакетах и старых проектах, поэтому читать его нужно уметь. Файл с расширением .cjs (или .js без "type": "module" в package.json) Node.js загружает как CommonJS.

Классическая ошибка — присвоить новый объект переменной exports.

// greet-bad.js - неверно
exports = {
  hello(name) {
    return `Привет, ${name}`;
  },
};

require('./greet-bad.js') вернет пустой объект {}. Причина: exports — лишь локальная ссылка на module.exports, а наружу уходит именно module.exports. Присваивание рвет ссылку, и функция никуда не экспортируется. Исправление:

// greet.js - верно
module.exports = {
  hello(name) {
    return `Привет, ${name}`;
  },
};
// app.js
const bad = require('./greet-bad.js');
const good = require('./greet.js');

console.log(bad);             // что вернул require для неверного модуля
console.log(good.hello('Otus'));
{}
Привет, Otus

Правило простое: либо дописывать свойства (exports.hello = ...), либо целиком заменять module.exports, но не присваивать exports.

ESM и CommonJS: сравнение

Что сравниваем ES-модули (ESM) CommonJS (CJS)
Синтаксис import / export require() / module.exports
Где работает браузеры, Node.js, Deno, Bun Node.js, Bun, Deno 2 и бандлеры; в браузере без сборки нет
Связь с экспортом живая ссылка, только чтение значение на момент экспорта
Строгий режим всегда только с 'use strict'
Top-level await есть нет
Путь к текущему файлу import.meta.filename, import.meta.dirname __filename, __dirname
Расширение в относительном импорте обязательно (./math.mjs) можно опускать
Как Node.js узнает формат .mjs или "type": "module" .cjs или .js без "type": "module"

Для нового кода в 2026 году разумный выбор по умолчанию — ESM: это стандарт языка, он одинаково работает в браузере и на сервере. CommonJS остается, когда проект или его зависимости на нем построены и переход не окупается.

Как Node.js выбирает формат

Для .js файла Node.js смотрит поле "type" в ближайшем package.json: "module" — ESM, "commonjs" или отсутствие поля — CommonJS. Расширения .mjs и .cjs задают формат явно и перекрывают это поле.

Если поля "type" нет, а в .js файле встречается import, Node.js 24 перезапустит разбор файла как ES-модуль. Когда рядом есть package.json без "type", он выводит предупреждение:

(node:15) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///w/det2/app.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /w/det2/package.json.

Полагаться на эту догадку не стоит: в старых версиях Node.js ее нет, а повторный разбор стоит времени. Надежнее явно прописать "type": "module".

Смешивание ESM и CommonJS

Из ESM можно импортировать CommonJS: объект module.exports приходит как default-импорт. Именованные импорты тоже работают, если Node.js может найти имена статическим анализом (например, для exports.sum = ...).

// named.cjs
exports.sum = (a, b) => a + b;
// named.mjs
import { sum } from './named.cjs';
console.log(sum(2, 3)); // 5

Обратное направление ограничено. В Node.js 24 require() умеет загружать ES-модуль, но только синхронный — без top-level await. Для асинхронного модуля остается динамический import():

// lib.mjs
export const answer = 42;
export default function hi() {
  return 'hi from ESM';
}
// tla.mjs - модуль с top-level await
const config = await Promise.resolve({ port: 8080 });
export default config;
// app.cjs
const lib = require('./lib.mjs');
console.log(lib.answer, lib.default());

try {
  require('./tla.mjs');
} catch (e) {
  console.log(e.code);
}

import('./tla.mjs').then((m) => console.log(m.default.port));
42 hi from ESM
ERR_REQUIRE_ASYNC_MODULE
8080

Граница: require() для ES-модулей — сравнительно новая возможность Node.js. В старых версиях тот же вызов падает с другой ошибкой, поэтому библиотеке, которая должна работать на них, лучше не опираться на это поведение.

Динамический import()

Статический import стоит в начале файла и загружается до выполнения кода. import() — выражение: его можно вызвать в условии или обработчике, и оно возвращает промис с объектом модуля. Так подгружают тяжелые части по требованию (в браузере бандлеры выносят их в отдельные файлы).

// main.mjs
const kind = process.argv[2] ?? 'json';

if (kind === 'csv' || kind === 'json') {
  // модуль загружается только сейчас, по требованию
  const { format } = await import('./format.mjs');
  console.log(format([1, 2, 3], kind));
}

try {
  await import('./missing.mjs');
} catch (e) {
  console.log(e.code);
}
// format.mjs
export function format(data, kind) {
  return kind === 'csv' ? data.join(';') : JSON.stringify(data);
}

node main.mjs печатает [1,2,3], node main.mjs csv — 1;2;3, в обоих случаях затем ERR_MODULE_NOT_FOUND для несуществующего файла. Обратите внимание на белый список kind: подставлять в import() путь прямо из пользовательского ввода нельзя, иначе можно загрузить произвольный файл.

Модули в браузере

В браузере ES-модуль подключают атрибутом type="module":

<!doctype html>
<html lang="ru">
  <body>
    <script type="importmap">
      { "imports": { "utils/": "/js/utils/" } }
    </script>
    <script type="module">
      import { area } from '/js/math.mjs';
      import { log } from 'utils/log.mjs';
      log(area(2));
    </script>
  </body>
</html>

Чем модульный скрипт отличается от обычного:

  • выполняется отложенно, после разбора HTML, как с defer;
  • работает в строгом режиме, его переменные не попадают в window;
  • загружается по правилам CORS, поэтому страница, открытая как file://, модули обычно не загрузит — нужен локальный веб-сервер;
  • «голые» имена вроде utils/log.mjs работают только через карту импортов (importmap), иначе браузер ждет путь, начинающийся с /, ./, ../ или полный URL.

В реальных проектах браузерный код чаще собирают бандлером (Vite, webpack, esbuild и другие), но синтаксис import/export в исходниках остается тем же.

До ESM: IIFE, AMD, UMD

До стандарта модули имитировали функцией, которая сразу вызывается (IIFE). Замыкание прятало переменные, наружу возвращался объект:

var counter = (function () {
  var count = 0; // не видна снаружи
  return {
    inc: function () { count += 1; return count; },
  };
})();

console.log(counter.inc(), counter.inc()); // 1 2
console.log(typeof count);                 // undefined

На этом приеме выросли форматы AMD (асинхронная загрузка через define, библиотека RequireJS) и UMD (обертка, которая работает и как AMD, и как CommonJS, и как глобальная переменная). В новом коде они не нужны, но встречаются в старых библиотеках и в сборках пакетов.

Если не получилось

Симптом Причина Что сделать
SyntaxError: Cannot use import statement outside a module файл загружен как CommonJS (например, .cjs) переименовать в .mjs или указать "type": "module"
ReferenceError: require is not defined in ES module scope, you can use import instead require в ES-модуле заменить на import; для JSON и CJS можно createRequire(import.meta.url) из node:module
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/w/err/math' imported from /w/err/main.mjs в относительном импорте ESM нет расширения писать путь полностью: ./math.mjs
ERR_REQUIRE_ASYNC_MODULE require() модуля с top-level await загрузить через import()
require('./x.js') вернул {} присвоено exports = ... заменить на module.exports = ...
ReferenceError: __dirname is not defined in ES module scope в ESM нет этих переменных import.meta.dirname и import.meta.filename

Выводы

  • Модуль в JavaScript — файл с собственной областью видимости; наружу выходит только экспорт.
  • ES-модули (import/export) — стандарт языка и выбор по умолчанию для нового кода в браузере и Node.js; CommonJS (require) нужно уметь читать и поддерживать.
  • Импорт в ESM — живая ссылка на переменную модуля, в CommonJS — значение на момент экспорта.
  • Формат файла в Node.js определяют расширение (.mjs/.cjs) и поле "type" в package.json; лучше задавать его явно.
  • Динамический import() загружает модуль по требованию и работает и в ESM, и в CommonJS.

Где применяется / связь с практикой

Освойте тему на практике

Модули — основа любого проекта на JavaScript: от npm-пакета до фронтенда на React и сервера на Node.js. На практике с ними сталкиваются при переводе проекта с CommonJS на ESM, настройке сборщика и публикации своих пакетов. Системно эти темы разбирают на курсе «JavaScript-разработчик. Продвинутый уровень». Посмотреть формат занятий можно на открытых уроках Otus.

FAQ

Можно ли в одном файле использовать и import, и require?
Напрямую нет: в ES-модуле require не определен, а в CommonJS-файле import — синтаксическая ошибка. В ESM require можно получить через createRequire, а в CommonJS доступен динамический import().

Нужен ли бандлер, если браузеры понимают ES-модули?
Для небольших страниц можно обойтись без него. В больших проектах бандлер объединяет сотни модулей в несколько файлов, удаляет неиспользуемый код и обрабатывает TypeScript и CSS.

Чем export default хуже именованного экспорта?
Не хуже, но у default нет фиксированного имени: в разных файлах его импортируют под разными именами, что усложняет поиск и автоматическое переименование. Поэтому многие команды предпочитают именованные экспорты.

OTUS Журнал
Бесплатные открытые уроки (поп-ап)