Исторически Node.js развивался вокруг собственной модульной спецификации CommonJS (CJS), разработанной в 2009 году. Модель CJS основана на синхронной загрузке модулей: функция require() читает файл с диска, синхронно компилирует его в функцию-обертку, выполняет тело модуля и помещает экспортированный объект module.exports в системный кэш require.cache. Эта модель отлично подходила для монолитных серверных сред, но фундаментально противоречила асинхронной природе веба и препятствовала статическому анализу кода (Tree-shaking).
Начиная с редакции ECMAScript 2015 (ES6) и полноценной интеграции в Node.js, стандартом индустрии стала нативная спецификация ECMAScript Modules (ESM). Архитектура ESM разделяет процесс загрузки на три изолированные фазы: статический синтаксический анализ графа зависимостей, связывание живых ссылок (Live Bindings) и непосредственное вычисление кода.
Современный файл package.json выступает не просто списком зависимостей, а манифестом архитектуры пакета. С помощью поля "type": "module" и карты субпафовых экспортов "exports" разработчик строго инкапсулирует внутренности библиотеки, определяя публичные точки входа и поддерживая дуальную совместимость (Dual Package) для различных сред выполнения.
В CommonJS модули вычисляются динамически прямо в момент вызова require(): код может содержать условные операторы if (condition) require('./a'), а возвращаемое значение является обычным снимком объекта (копированием значения). В ESM весь граф импортов строится статически ДО выполнения первой строчки исполняемого кода, гарантируя детерминированность структуры зависимостей.
[ CommonJS: Синхронное пошаговое вычисление ]
index.js -> require('./math.js') -> Чтение файла с диска -> Выполнение math.js -> Возврат module.exports
[ ESM: Трехфазный статический конвейер ]
Фаза 1: Construction (Парсинг всех import, построение графа модулей без исполнения кода)
|
Фаза 2: Instantiation (Выделение ячеек памяти и связывание Live Bindings)
|
Фаза 3: Evaluation (Исполнение кода модулей от листьев дерева к корню, Top-Level Await)Рекурсивный поиск файлов, чтение с диска, синтаксический разбор директив `import` и создание Module Record для каждого узла.
Выделение слотов в оперативной памяти для экспортируемых идентификаторов. Связывание `import` с ячейками `export` через неизменяемые Live Bindings.
Запуск байткода V8 в порядке топологической сортировки зависимостей. Допустимо синхронное ожидание промисов через Top-Level Await.
Результаты вычисления сохраняются во внутреннем Module Map рантайма. Повторные импорты возвращают уже инициализированный экземпляр.
Модуль exporter.js экспортирует переменную `let count = 0;` и функцию `increment() { count++; }`. Модуль consumer импортирует `count` и вызывает `increment()`. Как изменится значение переменной `count` в модуле consumer при использовании ESM по сравнению с CommonJS?
"exports" в package.json корпоративные SDK скрывают утилитарные файлы и хелперы, предотвращая случайный импорт приватных зависимостей сторонними командами (import 'pkg/internal/hack' блокируется рантаймом)..d.ts).(async () => { ... })().По умолчанию файлы с расширением .js в Node.js трактуются как CommonJS. Чтобы включить современный режим ESM для всех файлов проекта, необходимо указать "type": "module" в корневом файле конфигурации package.json.
{
"name": "enterprise-backend-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.8.0"
}
}Правила разрешения расширений:
.js: интерпретируется как ESM, если в ближайшем package.json указано "type": "module", иначе как CommonJS..mjs: всегда интерпретируется как ECMAScript Module независимо от package.json..cjs: всегда интерпретируется как CommonJS независимо от package.json.В отличие от устаревшего алгоритма CommonJS, который автоматически перебирал расширения (./utils, ./utils.js, ./utils.json, ./utils/index.js), резолвер ESM в Node.js требует строгого указания расширения файла:
// Экспорт именованных сущностей и констант
export const PI = 3.1415926535;
export function add(a: number, b: number): number {
return a + b;
}
// Внутреннее изменяемое состояние для демонстрации Live Bindings
export let executionCount = 0;
export function trackExecution(): void {
executionCount++;
}// ВНИМАНИЕ: Обязательно указывать относительное расширение .js (даже при компиляции из TS)
import { PI, add, executionCount, trackExecution } from './math/calculator.js';
console.log('Math Constant PI:', PI);
console.log('Result of add(10, 20):', add(10, 20));
console.log('Initial Execution Count:', executionCount); // 0
trackExecution();
console.log('Updated Execution Count via Live Binding:', executionCount); // 1Поле "exports" полностью заменяет устаревшее поле "main" и является современным стандартом инкапсуляции пакетов:
{
"name": "@company/auth-sdk",
"version": "2.4.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./tokens": {
"types": "./dist/tokens.d.ts",
"import": "./dist/tokens.js"
},
"./package.json": "./package.json"
}
}Что дает такая структура:
import '@company/auth-sdk/dist/internal-secret.js' вызовет фатальную ошибку резолвера ERR_PACKAGE_PATH_NOT_EXPORTED.import { verifyToken } from '@company/auth-sdk/tokens'..cjs, а потребители ESM — нативный .js.Определение имени пакета (`@company/auth-sdk`) и субпути (`/tokens`). Поиск манифеста package.json в node_modules.
Строгая проверка имени пакета согласно спецификации npm.
Поиск ключа `./tokens` в объекте `exports`. Если ключ отсутствует — выбрасывается ERR_PACKAGE_PATH_NOT_EXPORTED.
Любые не указанные файлы физически закрыты от импорта.
Выбор ветки: проверка условий `types` (при тайпчеке), `import` (для ESM), `node` (для серверной среды).
Порядок ключей в объекте conditions имеет решающее значение.
Чтение физического файла по разрешенному относительному пути и передача в фазу компиляции V8.
Результат кэшируется в Module Cache.
Сторонняя библиотека 'super-lib' имеет в package.json поле 'exports': { '.': './index.js' }. Внутри репозитория библиотеки находится файл './utils/crypto-helper.js'. Что произойдет при попытке вызвать import { decrypt } from 'super-lib/utils/crypto-helper.js' в вашем коде?
В модулях ESM переменные __dirname и __filename отсутствуют, обращение к ним выбрасывает ReferenceError: __dirname is not defined. Для получения пути к текущему файлу и директории в ESM используйте современные свойства:
import { fileURLToPath } from 'node:url';
import path from 'node:path';
// Современный стандарт Node.js 20.11+
const currentFile = import.meta.filename;
const currentDir = import.meta.dirname;
// Универсальный кроссплатформенный подход
const __filenameFallback = fileURLToPath(import.meta.url);
const __dirnameFallback = path.dirname(__filenameFallback);В коде TypeScript под ESM частой ошибкой является импорт без указания расширения: import { helper } from './helper'. Node.js при запуске скомпилированного кода выбросит ошибку ERR_MODULE_NOT_FOUND. В ESM всегда необходимо указывать расширение: import { helper } from './helper.js'.
Если библиотека экспортирует две независимые сборки (одна для CJS через require(), вторая для ESM через import()), и обе сборки содержат изолированное состояние (например, инстанс реестра соединений с БД new DatabaseClient()), то при смешанном использовании библиотеки в одном приложении возникнет два независимых экземпляра синглтона. Для предотвращения этой проблемы CJS-обертка должна выступать тонким адаптером к ESM, либо синглтон должен выноситься в общий глобальный символ Symbol.for().