Перейти к содержанию

ES-модули Node.js: import/export и связывание

Источник: theNodeBook — ES Modules: import/export & Linking

ES-модули в Node.js опираются на статические записи модулей (module records). Важнее синтаксиса — конвейер загрузчика. Node определяет формат файла, разрешает каждый статический import, связывает граф зависимостей и только потом оценивает модули в порядке зависимостей. Формат выявляется по расширению файла, полю type в ближайшем package.json, явному режиму ввода и синтаксическому детектированию для неоднозначных случаев.

ES-модули в Node.js

.mjs — всегда ESM. .cjs — всегда CommonJS. .js следует границе пакета, если в package.json объявлен type; иначе Node может проанализировать исходник на ESM-only синтаксис перед откатом к CommonJS. Как только файл признан ESM, импорты разрешаются до оценки, экспорты — живые привязки (live bindings), а top-level await может отложить оценку зависимых модулей. Динамический import() входит в загрузчик ESM во время выполнения и возвращает промис объекта пространства имён модуля.

Как Node понимает, что перед ним ES-модуль

При каждой загрузке файла Node должен выбрать формат модуля. От этого зависят правила парсера, доступные глобальные переменные и структура экспортов. Ошибка на этом шаге — падение программы.

Решение принимается по фиксированному приоритету. Деталей больше, чем кажется на первый взгляд.

Расширение файла — первым. .mjs всегда ESM. .cjs всегда CJS. Без двусмысленности и без настроек. Файл с суффиксом .mjs попадает в загрузчик ES-модулей независимо от package.json.

Для .js Node поднимается по дереву каталогов и ищет ближайший package.json. Если там "type": "module", все .js в области этого пакета грузятся как ESM. Если "type": "commonjs" — как CJS. Если поля type нет, ввод неоднозначен. В Node v24 для таких .js действует синтаксическое детектирование: ESM-only синтаксис → ESM, иначе → CommonJS.

1
2
3
4
{
    "name": "my-app",
    "type": "module"
}

Одно поле "type": "module" переводит все .js пакета в ESM. Подкаталог может переопределить это своим package.json с другим type. Учитывается ближайший package.json, а не только корневой.

Есть также --input-type=module для строкового ввода через --eval или STDIN. Синтаксическое детектирование появилось за флагом --experimental-detect-module в Node v21.1.0 и v20.10.0, по умолчанию включено с v22.7.0 и в линейке 20 — с v20.19.0. Чтобы отключить поведение по умолчанию, используйте --no-experimental-detect-module. Node проверяет неоднозначный ввод на конструкции, которые в CommonJS вызвали бы ошибку: статические import и export, import.meta, top-level await и лексические повторные объявления обёрточных переменных вроде require, module, exports, __filename или __dirname. Путь детектирования полезен для совместимости, но явная конфигурация пакета чище — объявляйте тип модулей заранее.


Статический анализ

В CJS require() — обычный вызов функции. Его можно вызвать внутри if, собрать путь конкатенацией строк, вызвать в цикле. Runtime не знает, что вы require-ите, пока не дойдёт до этой строки.

ESM устроен иначе. import и exportобъявления, анализируемые на этапе разбора. Движок читает исходный текст, извлекает все import и export и строит полный граф зависимостей до выполнения хотя бы одной строки JavaScript. Спецификатор (строка после from) должен быть литеральной строкой. Вычислить его нельзя.

1
2
3
4
5
// Допустимо в CJS
const mod = require(condition ? './a.js' : './b.js');

// Синтаксическая ошибка в ESM
import something from (condition ? './a.js' : './b.js');

Вторая строка падает при разборе. Движок ещё не выполнял код, не вычислял condition — не может: разбор идёт раньше оценки. Спецификатор должен быть статической строкой, которую парсер извлекает, просто читая файл.

Это ограничение — суть модели. Зная все зависимости на этапе разбора, движок строит граф, обнаруживает циклы, выделяет привязки и оптимизирует до запуска кода. CJS — открытие зависимостей во время выполнения. ESM — объявление на этапе «компиляции» модуля.

Практическое следствие: инструменты могут анализировать граф импортов без запуска кода. Бандлеры, tree-shaking, type checker’ы и линтеры этим пользуются. Если вы export function foo(), а никто не импортирует foo, бандлер может доказать мёртвый код и выкинуть его. В CJS такое доказательство невозможно: любой модуль может require() ваш в любой момент, а путь к require() может быть спрятан в ветке, которую инструмент не вычислит.

Статический анализ ловит ошибки раньше. Импорт имени, которого нет в целевом модуле, даёт ошибку на этапе связывания — до логики приложения. В CJS вы получили бы undefined и узнали бы об этом позже, часто в продакшене.


Трёхфазный конвейер загрузки

Загрузка ESM в Node — три фазы: разбор (parsing), инстанцирование (instantiation) и оценка (evaluation). Фазы идут последовательно. Граница между ними объясняет большую часть поведения ESM.

Разбор

Node читает исходник и разбирает его по грамматике ES-модуля. Здесь же статический анализ — то, что отличает ESM от CJS. Парсер извлекает два списка: все объявления import и все export. Ничего не выполняется, импортированные значения не разрешаются — только фиксируется, какие имена откуда импортируются и какие имена экспортируются.

Для каждого import Node разрешает спецификатор в URL (подробнее — в разделе про URL), загружает исходник целевого модуля и разбирает его тоже. Рекурсивно: если a.js импортирует b.js, а тот — c.js, все три получают разбор до запуска кода.

Результат фазы 1 — граф модулей: ориентированный граф, узлы — разобранные модули, рёбра — связи импорта. Каждый модуль в графе загружен и разобран, но ни один не выполнен.

Инстанцирование

Самая неочевидная фаза. Имея полный граф, движок создаёт инфраструктуру привязок. Для каждого модуля выделяются «ячейки» под каждый объявленный экспорт. Сначала они не инициализированы — значений ещё нет.

Затем для каждого импорта в графе создаётся живая ссылка с импортированного имени в модуле-импортёре на слот экспорта в модуле-экспортёре. Два имени указывают на одно место в памяти. Если модуль A импортирует count из B, идентификатор count в A напрямую связан со слотом count в B.

Код ещё не выполнялся. Значений нет. Но проводка готова: каждый импорт во всём графе подключён к соответствующему экспорту. Импорт или реэкспорт имени, которого целевой модуль не объявляет, даёт SyntaxError на этапе связывания, до оценки.

1
2
3
4
5
// b.js
export let count = 0;

// a.js
import { count } from './b.js';

После инстанцирования count в a.js и count в b.js — одна привязка, один слот. Значение заполнится в фазе 3, но ссылка уже установлена.

Оценка

Проводка готова — пора выполнять код. В ациклическом графе сначала оцениваются зависимости, потом зависимые: листья, затем их импортёры и так далее. К моменту запуска модуля его зависимости обычно уже оценены. Циклы и top-level await усложняют картину: привязка может существовать до инициализации значения.

Top-level await (см. главу 1) приостанавливает оценку текущего модуля и всего, что от него зависит, пока промис не разрешится. Если в b.js есть top-level await, а a.js импортирует из b.js, a.js не начнёт оценку, пока await в b.js не завершится.

Каждый модуль оценивается ровно один раз. Результат кэшируется. Повторный импорт того же модуля даёт тот же кэш пространства имён — те же слоты и те же значения (или то, во что привязки «живут» сейчас).

Разделение на три фазы — цена, которой CJS не платит. CJS грузит модули по одному, в глубину, выполняя файл при каждом require(). ESM сначала должен обнаружить статический граф, потом запускать его код. Для глубокого дерева чтение и разбор идут заранее. Выигрыш — более жёсткая проверка: импорты проверены, статические зависимости разрешены, циклы представлены живыми привязками, а не «наполовину собранным» объектом exports.


Синтаксис import

Именованные импорты

Самая частая форма — конкретные именованные привязки:

1
2
import { readFile, writeFile } from 'node:fs/promises';
import { EventEmitter } from 'node:events';

Имена в фигурных скобках должны совпадать с именованными экспортами целевого модуля. Если node:fs/promises не экспортирует readFile, на этапе связывания будет SyntaxError. При импорте можно переименовать:

1
import { readFile as read } from 'node:fs/promises';

read — локальный псевдоним той же живой привязки, что и readFile в экспортирующем модуле. Новой копии значения не создаётся.

Импорт по умолчанию

1
import EventEmitter from 'node:events';

Импортируется привязка с именем default. Локальное имя EventEmitter произвольно — это псевдоним того, что модуль отдал как default. На уровне привязок default — обычный экспорт с именем "default".

Импорт пространства имён

1
import * as fs from 'node:fs/promises';

fs — объект пространства имён модуля (module namespace exotic object): свойства отражают именованные экспорты (fs.readFile, fs.writeFile и т.д.). Добавить свойство или переназначить существующее нельзя. Object.isFrozen(fs) может быть false, потому что дескрипторы выглядят записываемыми для живых экспортов, хотя присваивание отклоняется. Значения за свойствами меняются, когда экспортирующий модуль обновляет привязку.

Импорт ради побочных эффектов

1
import './setup.js';

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

Типично для полифиллов, инструментирования, конфигурационных модулей. Модуль всё равно разбирается, его зависимости разрешаются и связываются, код выполняется — просто в импортёре нет новых имён.

Комбинированные формы

Default и именованные импорты в одном объявлении:

1
import fs, { readFile, writeFile } from 'node:fs';

fs — default; readFile и writeFile — именованные. Default — тот же "default", что и любой именованный экспорт в одной декларации.


Синтаксис export

Именованные экспорты

Префикс export у объявления:

1
2
3
4
5
6
7
export const PORT = 3000;
export function startServer() {
    /* ... */
}
export class Router {
    /* ... */
}

Создаются именованные экспорты PORT, startServer, Router. Можно экспортировать уже существующие привязки списком:

1
2
3
4
5
const PORT = 3000;
function startServer() {
    /* ... */
}
export { PORT, startServer };

Тот же результат, другой синтаксис. При экспорте можно переименовать:

1
export { startServer as start };

Наружу уходит имя start, локально — startServer.

Экспорт по умолчанию

1
2
3
export default function createApp() {
    /* ... */
}

Функция становится привязкой default. Один default на модуль. default — имя экспорта, но формы export default function и export default class ведут себя по-разному с точки зрения имён: у export default function() {} свойство .name"default", у export default class {} имя класса — "default".

export default принимает любое выражение: export default 42, export default { foo: 1, bar: 2 } — выражение вычисляется, результат попадает в привязку default.

export default с выражением не создаёт живой экспорт исходной переменной. При export default count default получает текущее значение count на момент оценки. Если count потом изменится, default останется прежним. Именованный export { count } сохраняет живую ссылку. Эта асимметрия часто путает.

Реэкспорт

Можно пробросить привязки без ввода их в локальную область:

1
2
export { readFile, writeFile } from 'node:fs/promises';
export { default as EventEmitter } from 'node:events';

В первом случае readFile и writeFile доступны из этого модуля, но локальных переменных с такими именами нет — прямой проброс. Во втором default из node:events реэкспортируется как именованный EventEmitter.

Проброс всего:

1
export * from './utils.js';

Все именованные экспорты ./utils.js становятся именованными экспортами текущего модуля. Default в export * не входит — его реэкспортируют явно. Если два export * дают одно имя, а потребитель импортирует это имя без уточнения, на этапе связывания будет ошибка неоднозначности.

Реэкспорт — основа barrel-файлов (index.js, собирающих публичный API) и курируемых entry point пакетов. V8 может связать привязку напрямую от источника к потребителю без лишнего цикла import/export в промежуточном файле.


Живые привязки

Здесь ESM расходится с типичным использованием CJS. require() возвращает ссылку на объект module.exports. Пока вы держите объект, поздние изменения свойств видны. При деструктуризации свойства копируется значение в локальную привязку.

В ESM импортёр получает не копию, а ссылку на слот экспорта. Изменение у экспортёра сразу видно у импортёра.

1
2
3
4
5
// counter.js
export let count = 0;
export function increment() {
    count++;
}
1
2
3
4
5
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1

count в main.js — не локальная переменная со значением 0, а живая ссылка на count в counter.js. После increment() в main.js сразу 1.

Импортёр может читать привязку, но не переназначать: count = 5 в main.jsTypeError. Менять значение может только модуль-владелец экспорта.

Сравнение с деструктурированным CJS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// counter.cjs
exports.count = 0;
exports.increment = () => {
    exports.count++;
};

// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // всё ещё 0 — это копия

CJS скопировал число 0 в локальную переменную. increment() меняет exports.count в counter.cjs, локальная копия в main.cjs не обновляется. Если бы main.cjs держал весь объект require('./counter.cjs'), counter.count отразил бы мутацию.

ESM убирает «снимок» при именованном импорте: каждое чтение — актуальное значение привязки. Мутации объектов и default-экспорт из выражений требуют отдельного внимания, но именованные импорты не «застывают».

Живые привязки объясняют, почему циклические импорты в ESM иногда работают: при инстанцировании слоты уже есть, A и B ссылаются друг на друга. Важен момент доступа. Экспорт var до присваивания можно увидеть как undefined. Экспорт let, const или class до строки объявления — ReferenceError (temporal dead zone). Привязка есть, значения может ещё не быть.

Для const привязка формально «живая», но после инициализации значение не меняется. Если const экспортирует объект, ссылка на объект фиксирована, свойства объекта менять можно — и это видно через живую привязку, потому что оба модуля указывают на один объект в памяти.


Выражение import()

Статические import покрывают большинство случаев. Иногда модуль нужен условно, лениво или со спецификатором, вычисленным в runtime. Для этого — import().

1
2
const mod = await import('./heavy-module.js');
mod.doSomething();

import() выглядит как вызов функции, но это синтаксическая форма (оператор). Нельзя сделать const myImport = import (синтаксическая ошибка), нельзя .bind() и передать как колбэк. Принимает строку-спецификатор и возвращает промис пространства имён. Спецификатор может быть переменной или любым выражением:

1
2
const lang = getUserLanguage();
const i18n = await import(`./locales/${lang}.js`);

Это единственный способ в ESM загрузить модуль условно или отложенно. Статический import так не умеет.

import() работает и в ESM, и в CJS — один из мостов межформатного взаимодействия. Объект пространства имён содержит именованные экспорты как свойства и default для default-экспорта.

Если разрешённый URL уже загружался, import() не переоценивает модуль, а возвращает кэшированное пространство имён.

Поскольку результат — промис, удобно async/await. Появляется асинхронная граница. В CJS require() синхронен: модуль загружен и выполнен до возврата. import() запускает процесс и откладывает результат. Синхронную инициализацию из экспортов придётся перестроить — отсюда привычка иногда оставлять require() на критичных путях старта даже в ESM-кодовой базе.


Разрешение модулей через URL

Внутри Node ESM опирается на URL. Файловые модули → file: URL. Встроенные — node: URL. Встроенные в строку — data: URL. CJS в основном работал с путями файловой системы.

1
2
import { something } from './utils.js';
// Разрешается в: file:///Users/dev/project/utils.js

При обычных относительных импортах это редко заметно. Становится важным при кэшировании: полный URL — идентичность модуля (подробнее в следующей подглаве).

«Голые» спецификаторы без ./, ../ и без / в начале проходят разрешение пакетов и node_modules. Пакет пользователя обычно даёт file: URL — его и сравнивают в кэше.

Схема data: создаёт модуль из содержимого URL:

1
import { name } from 'data:text/javascript,export const name="inline"';

В основном тесты и экзотические инструменты, но это часть URL-модели. node:fs и другие node: URL обходят файловую систему и грузятся из встроенного набора Node.

Обязательные расширения файлов

В относительных импортах ESM в Node нужно явное расширение:

1
2
3
4
5
6
7
8
9
// Работает в CJS
const utils = require('./utils');

// Падает в ESM
import utils from './utils';
// Error [ERR_MODULE_NOT_FOUND]

// Нужно явно
import utils from './utils.js';

CJS пробует .js, .json, .node. ESM — нет. Так задумано: как в браузере, URL с расширением, проще и предсказуемее алгоритм.

Для node: и bare specifiers из node_modules расширения задаёт поле exports в package.json. Для относительных и абсолютных путей расширение пишете сами.

Импорт каталога (import './utils/' в надежде на ./utils/index.js) по умолчанию не работает. В CJS искали index.js в каталоге; в ESM нужен полный путь к файлу. Флаг --experimental-specifier-resolution=node в текущих сборках — режим совместимости, а не нормальный резолвер. Практика — явные пути.


Как Node реализует ESM

Загрузчик ESM живёт в lib/internal/modules/esm/ исходников Node. Слои отвечают за этапы конвейера. Внутренние имена меняются между версиями — ориентиры для чтения исходников, не публичный API приложений.

ModuleLoader

В Node v24 в loader.js центральную роль играет ModuleLoader: разрешение, загрузка, трансляция, связывание, оценка. На import { foo } from './bar.js':

  1. Разрешить './bar.js' относительно родителя в URL.
  2. Загрузить исходник, определить формат: module, commonjs, json, wasm, builtin.
  3. Создать или взять из кэша ModuleJob для URL и формата.

ModuleLoader кэширует разрешённые спецификаторы и загруженные job’ы. Повторный импорт того же URL — один экземпляр модуля.

Пользовательские хуки могут перехватывать resolve и load. Асинхронные хуки через module.register() или --loader выполняются в отдельном потоке хуков. Синхронные module.registerHooks() — в том же потоке и затрагивают и CommonJS. Это важно, если загрузчик делит состояние с приложением или ждёт синхронного разрешения.

ModuleJob

На каждый модуль в графе — экземпляр ModuleJob (module_job.js): жизненный цикл одного модуля через все три фазы, ссылка на ModuleWrap (V8-привязка), зависимости, координация связывания и оценки.

При создании job сразу разрешаются зависимости: для каждого import запрашивается соответствующий ModuleJob у ModuleLoader (что может запустить цепочку для новых URL). Получается дерево job’ов, зеркало графа модулей.

job.run() ведёт модуль через инстанцирование и оценку: module.instantiate() на ModuleWrap (связывание V8), затем module.evaluate() (выполнение кода). При top-level await evaluate() возвращает промис, который резолвится после await.

ModuleWrap

ModuleWrap (lib/internal/vm/module.js, C++ в src/module_wrap.cc) — мост между JS-загрузчиком Node и C++ API модулей V8. Оборачивает v8::Module.

Из исходника вызывается v8::ScriptCompiler::CompileModule(): AST, списки import/export, v8::Module в состоянии «не инстанцирован». Есть GetModuleRequests(), GetModuleNamespace(), отслеживание статуса.

Инстанцирование — v8::Module::InstantiateModule(): обход графа, callback разрешения импортов, Node находит ModuleWrap зависимостей, V8 создаёт слоты экспортов и перекрёстные ссылки. На этом уровне живые привязки — изменяемые внутренние слоты, не свойства обычного объекта.

Оценка — v8::Module::Evaluate(): байткод через Ignition/Sparkplug/TurboFan, как у скриптов, но top-level всегда strict, своя область модуля, экспорты в Cell, а не на exports. Возвращается v8::Promise: без top-level await уже resolved; с await — pending, пока операция не завершится. ModuleJob подписывается на этот промис.

SourceTextModule и SyntheticModule

V8 различает модули из исходного текста и синтетические модули с заранее заданными экспортами. JSON-модули в Node: парсинг JSON и синтетический модуль с одним default, содержащим объект. Поэтому у JSON только default — нет исходника с export для парсера.


Объект пространства имён модуля

При import * as ns from './module.js' переменная ns — объект пространства имён. Он создаётся при инстанцировании; свойства соответствуют именованным экспортам. Object.isSealed(ns)true: нельзя добавить, удалить или перенастроить свойства. Чтение живое: ns.count — текущее значение привязки count. Дескрипторы могут выглядеть записываемыми, присваивание отклоняется по правилам спецификации для namespace exotic object.

1
2
3
4
5
import * as counter from './counter.js';
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 1
console.log(Object.keys(counter)); // ['count', 'increment']

Default доступен как свойство "default": counter.default, хотя так обращаются редко.

Прототип — null (Object.getPrototypeOf(ns) === null). Symbol.toStringTag"Module", поэтому Object.prototype.toString.call(ns) даёт "[object Module]".


Strict mode, области видимости и прочие отличия

Код ES-модуля всегда в strict mode — директива "use strict" не нужна. this на верхнем уровне — undefined (не globalThis), присваивание необъявленной переменной бросает ошибку, дублирующиеся параметры запрещены, with недопустим.

У каждого модуля своя область. Верхнеуровневые переменные локальны, пока не экспортированы. В CJS область тоже изолирована обёрткой, но в ESM это закреплено в Environment Record модуля в V8.

На верхнем уровне ES-модуля нет arguments, require, module, exports, __filename, __dirname — это инъекции CJS-обёртки (глава 1). Импорты — через import, метаданные модуля — через import.meta (следующая подглава).

typeof и eval() в strict mode работают как обычно. new Function() создаёт функции в глобальной области, не в области модуля.

В CJS на верхнем уровне this === module.exports из-за обёртки. В ESM this === undefined. Портированный код, который писал свойства на this, «тихо» ломается: this станет undefined, обращение к свойству — TypeError.

JSON в ESM — с атрибутом импорта (стабильный синтаксис в современных версиях Node):

1
import config from './config.json' with { type: 'json' };

Без атрибута Node откажется загружать файл: загрузчик создаст SyntheticModule с распарсенным JSON как default. Альтернативы: fs.readFileSync + JSON.parse или createRequire() из node:module для прежнего require('./config.json').


Собираем картину

Три фазы — разбор, инстанцирование, оценка — дают поведение, непривычное после CJS. Пример взаимодействия:

1
2
3
4
5
6
// config.js
export const debug = process.env.DEBUG === '1';
export let requestCount = 0;
export function trackRequest() {
    requestCount++;
}
1
2
3
4
5
6
// server.js
import {
    debug,
    requestCount,
    trackRequest,
} from './config.js';

При разборе Node видит зависимость server.jsconfig.js, оба разбираются. При инстанцировании слоты debug, requestCount, trackRequest связываются с импортами в server.js. При оценке сначала выполняется config.js: читается process.env.DEBUG, инициализируется requestCount, определяется trackRequest.

Когда доходит очередь server.js, у debug уже есть значение, requestCount равен 0. Вызов trackRequest() из server.js сразу обновляет requestCount в обоих модулях — живых привязок и «устаревших копий» нет.

Итог: статические объявления дают движку полный граф до выполнения; три фазы разделяют разбор, проводку и запуск; живые привязки убирают ловушку деструктуризации CJS; под капотом — API модулей V8 и координация ModuleLoader, ModuleJob и ModuleWrap в Node.

Связанное чтение

Комментарии