Модуль в JavaScript — это отдельный файл со своей областью видимости: переменные внутри него не попадают в глобальную область, а наружу выходит только то, что явно экспортировано. Сегодня в JS живут две системы модулей: стандартные ES-модули (import/export, работают в браузере и Node.js) и CommonJS (require/module.exports, исторический формат Node.js). Отдельно от них есть динамический import() — функция, которая загружает модуль во время выполнения.
Содержание
- Мини-словарь
- Минимальный пример: ES-модуль в Node.js
- Импорт — это живая связь, а не копия
- CommonJS: require и module.exports
- ESM и CommonJS: сравнение
- Как Node.js выбирает формат
- Смешивание ESM и CommonJS
- Динамический import()
- Модули в браузере
- До ESM: IIFE, AMD, UMD
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — как писать и подключать модули, чем 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 нет фиксированного имени: в разных файлах его импортируют под разными именами, что усложняет поиск и автоматическое переименование. Поэтому многие команды предпочитают именованные экспорты.



