Node.js Internals

Module System: CommonJS vs ESM

JavaScript был единственным mainstream языком БЕЗ встроенной модульной системы до 2015 года. PHP имел `include`, Python - `import`, Java - пакеты. Но JavaScript жил в браузерах, где всё загружалось через `<script>` теги в глобальный scope. Node.js изменил правила игры, введя CommonJS - первую практичную модульную систему для JS. Сегодня мы живём в переходном периоде: старый стандарт (CJS) против официального (ESM). Понимание обеих систем - это понимание истории и будущего JavaScript.

  • **Миграция с require() на import:** Половина npm пакетов уже мигрировала на pure ESM (node-fetch, chalk, execa). Если вы не понимаете interop, ваш проект застрянет на старых версиях зависимостей.
  • **Монорепозитории (Nx, Turborepo):** Смешивают CJS и ESM пакеты. Module resolution должен работать через workspace aliases и symlink'и. Без понимания алгоритма разрешения — непрерывные MODULE_NOT_FOUND.
  • **Tree-shaking и бандл-сайз:** ESM позволяет webpack/rollup удалять неиспользуемый код. Lodash весит 70KB, но `import { map }` добавит только 2KB в бандл. В CJS так не работает — приходится использовать `lodash.map` (отдельный пакет на каждую функцию).
  • **Serverless (Lambda, CloudFlare Workers):** Cold start время критично. ESM загружается асинхронно и параллельно, CJS — синхронно и последовательно. Разница в стартовом времени может быть 2-3x.

Intro

Представьте библиотеку, где все книги свалены в одну огромную кучу без разделения на разделы. Найти нужную главу - кошмар. До появления модулей весь JavaScript был таким: один глобальный scope, конфликты имён, невозможность переиспользовать код. Node.js решил эту проблему, введя **модульную систему** - способ разбить приложение на независимые файлы с чёткими границами и зависимостями.

Но история усложнилась: Node.js родился с **CommonJS** (2009), а затем JavaScript получил официальный стандарт - **ES Modules** (2015). Теперь у нас две модульных системы, которые работают по-разному и требуют понимания их внутренностей. Почему `require()` синхронный, а `import` асинхронный? Можно ли миксовать CJS и ESM? Как работает кеширование модулей? Почему `import` нельзя использовать условно?

**Ключевое различие:** CommonJS загружает модули **синхронно** и **во время выполнения** (runtime). ES Modules парсятся **статически** и загружаются **асинхронно**. Это не просто синтаксическая разница - это две философии дизайна с разными trade-off'ами.

Реальная проблема миграции

Вы работаете над Express приложением на CommonJS (тысячи строк кода). Решаете мигрировать на ESM для использования современных пакетов. Но: 1. Меняете `package.json`: `"type": "module"` 2. Заменяете `require()` на `import` 3. Запускаете - приложение падает с ошибкой `ERR_REQUIRE_ESM` **Почему?** У вас есть зависимости, которые экспортируют только ESM (например, `node-fetch v3`), но другие пакеты всё ещё используют CommonJS. Приходится понимать interoperability, использовать `.mjs` / `.cjs` расширения, менять build pipeline. Вот почему знание внутренностей критично - миграция требует понимания того, как Node.js различает форматы и разрешает зависимости.

Почему в ES Modules нельзя использовать вычисляемые пути (переменные) в import, в отличие от CommonJS require()?

CommonJS Internals

CommonJS - это не просто `require()` и `module.exports`. Под капотом Node.js делает магию: оборачивает каждый файл в функцию, создаёт объект `module` с метаданными, кеширует результаты, разрешает относительные и абсолютные пути через сложный алгоритм. Давайте разберём механику.

Когда вы пишете `const foo = require('./bar')`, происходит следующее: **1. Module Wrapping** - Node.js читает файл `bar.js` и оборачивает его в функцию: ``` (function(exports, require, module, __filename, __dirname) { // ваш код из bar.js }); ``` **2. Создание объекта module:** ``` module = { id: '/path/to/bar.js', exports: {}, // Пустой объект по умолчанию parent: [текущий модуль], filename: '/path/to/bar.js', loaded: false, children: [], paths: [список путей для поиска node_modules] }; ``` **3. Выполнение функции** - Node.js вызывает обёрнутую функцию с этими параметрами. Внутри вашего кода `module.exports` ссылается на `module.exports` из параметров. **4. Кеширование** - результат сохраняется в `require.cache['/path/to/bar.js']`. **5. Возврат** - возвращается `module.exports`.

**module.exports vs exports:** `exports` - это просто ссылка на `module.exports`. Можно делать `exports.foo = 'bar'`, но НЕЛЬЗЯ переназначать `exports = { ... }`, потому что это создаст новую локальную переменную. Всегда используйте `module.exports` для экспорта объектов целиком.

Circular Dependencies - как CommonJS их разрешает

**Проблема:** ```js // a.js const b = require('./b'); module.exports = { name: 'A', b }; // b.js const a = require('./a'); // ← Циклическая зависимость! module.exports = { name: 'B', a }; ``` **Что происходит:** 1. `a.js` начинает загружаться, `module.exports = {}` 2. `a.js` вызывает `require('./b')` 3. `b.js` начинает загружаться, вызывает `require('./a')` 4. Node.js видит, что `a.js` уже в процессе загрузки (флаг `loaded: false`) 5. Возвращает **частично загруженный** `module.exports` из `a.js` (пустой объект на этот момент) 6. `b.js` получает `a = {}` 7. `b.js` завершается, возвращает управление в `a.js` 8. `a.js` завершается с полным `module.exports` **Результат:** `b.a` будет пустым объектом, а `a.b` - полным. Циклические зависимости работают, но ведут к bugs. Используйте dependency injection или рефакторинг.

Что произойдёт, если внутри модуля написать exports = { foo: 'bar' } вместо module.exports = { foo: 'bar' }?

ES Modules Internals

ES Modules (ESM) - официальный стандарт JavaScript, принятый в ECMAScript 2015. В отличие от CommonJS, который был изобретён для Node.js, ESM работает одинаково в браузерах и на сервере. Но дьявол в деталях: ESM в Node.js имеет нюансы, связанные с обратной совместимостью и производительностью.

**Ключевые отличия от CommonJS:** **1. Статический анализ** - граф зависимостей строится ДО выполнения кода. Это позволяет: - Tree-shaking (удаление неиспользуемых экспортов в бандлерах) - Статическую проверку ошибок импорта на этапе парсинга - Круговые зависимости без проблем (все экспорты известны до выполнения) **2. Асинхронная загрузка** - `import` возвращает Promise в динамическом виде. Модули могут загружаться параллельно. **3. Живые привязки (live bindings)** - экспорты в ESM ссылаются на оригинальные значения, а не копируют их. Изменения в экспортирующем модуле видны в импортирующем. **4. Строгий режим** - все ESM файлы автоматически в strict mode. Нельзя использовать устаревшие фичи вроде `arguments.caller` или `with`.

**Top-level await:** В ESM можно использовать `await` на верхнем уровне модуля без async функции. Это блокирует загрузку зависимых модулей, пока Promise не разрешится. Используйте с осторожностью - можно заблокировать весь граф зависимостей.

Tree-shaking в действии

**Библиотека (lodash-es):** ```js // lodash.mjs export function map(arr, fn) { ... } export function filter(arr, fn) { ... } export function reduce(arr, fn, init) { ... } // ... 300+ функций ``` **Ваш код:** ```js import { map } from 'lodash-es'; const result = map([1, 2, 3], x => x 2); ``` **Webpack/Rollup анализ:** - Парсит `import { map }` → используется только `map` - Остальные 299 функций помечаются как "dead code" - Финальный бандл: ~2KB вместо 70KB **Почему это невозможно с CommonJS?** ```js const lodash = require('lodash'); // Node.js ОБЯЗАН выполнить весь lodash/index.js const map = lodash.map; // Бандлер не знает, что используется только map ``` Вот почему современные библиотеки (lodash-es, date-fns) предлагают ESM версии.

Ваш модуль экспортирует счётчик. В CommonJS count остаётся 0 после increment(), в ESM становится 1. Почему?

Module Resolution Algorithm

Когда вы пишете `import express from 'express'`, как Node.js находит этот пакет? За простым синтаксисом скрывается сложный алгоритм разрешения путей, который проверяет десятки возможных файлов и директорий. Понимание этого алгоритма критично для отладки `MODULE_NOT_FOUND` ошибок и настройки монорепозиториев.

**Детальный алгоритм для `import 'foo'` из `/project/src/app.mjs`:** **1. Проверка built-in модулей** - если 'foo' совпадает с встроенным модулем (fs, path, http, etc.), используется он. Node.js 16+ требует префикс `node:` для явности: `import fs from 'node:fs'`. **2. Поиск в node_modules иерархии:** ``` /project/src/node_modules/foo /project/node_modules/foo /node_modules/foo ``` **3. Для каждой директории проверяется:** - `package.json` → поле `"exports"` (новый стандарт) - `package.json` → поле `"main"` (старый стандарт) - `index.js` / `index.mjs` / `index.json` **4. Package.json "exports" (условный экспорт):** Пакет может экспортировать разные файлы для разных окружений: ```json { "exports": { "." : { "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.cjs", "types": "./dist/index.d.ts" }, "./submodule": "./dist/submodule.mjs" } } ``` ESM import использует `"import"`, CommonJS require - `"require"`.

**Важно:** В ESM расширения файлов обязательны для относительных путей: `import './foo.js'`, а не `import './foo'`. Это сделано для совместимости с браузерами, где import должен указывать полный URL.

Реальная проблема: dual packages

Вы публикуете библиотеку, которая должна работать и в CommonJS, и в ESM проектах. Как это сделать? **Плохое решение (двойная инстанциация):** ```json { "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs" } ``` **Проблема:** Если ESM проект импортирует вашу библиотеку, а одна из зависимостей использует require(), модуль загрузится ДВАЖДЫ - отдельные копии с отдельным state. Singleton паттерн сломается. **Правильное решение ("exports" с условиями):** ```json { "exports": { "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.js" } } ``` Node.js гарантирует, что модуль загрузится один раз, даже если используется и import, и require (через внутренний wrapper).

Проект с "type": "module" в package.json. Вы делаете import './config', файл называется config.js. Что произойдёт?

Module Caching

Модули загружаются один раз и кешируются. Это критично для производительности - без кеша каждый `require('./db')` читал бы файл с диска и парсил код заново. Но кеширование создаёт интересные эффекты и проблемы, которые нужно понимать.

**Как работает кеш:** **CommonJS:** `require.cache` - обычный объект `{ [абсолютный путь]: module }`. **ESM:** Внутренний Map в V8, недоступный напрямую (нет публичного API для очистки). **Ключ кеша:** Абсолютный путь после разрешения. Это значит: - `require('./foo')` и `require('./FOO')` на case-insensitive ФС (macOS/Windows) - один модуль - `require('lodash')` из разных node_modules - разные модули - Symlink'и разрешаются в реальные пути (один модуль даже через разные symlink'и)

**Singleton паттерн бесплатно:** Кеш модулей автоматически делает экспорты синглтонами. `const db = require('./db')` в 100 разных файлах вернёт тот же объект подключения. Это удобно, но опасно в тестах - нужно очищать state между тестами.

Circular dependencies через кеш

Кеш также разрешает циклические зависимости: ```js // a.js console.log('a starting'); const b = require('./b'); // b начинает загружаться console.log('in a, b.done =', b.done); module.exports = { done: true }; console.log('a done'); // b.js console.log('b starting'); const a = require('./a'); // a УЖЕ в кеше (loaded: false) console.log('in b, a.done =', a.done); // undefined! Модуль не завершён module.exports = { done: true }; console.log('b done'); ``` **Вывод:** ``` a starting b starting in b, a.done = undefined ← Частично загруженный модуль b done in a, b.done = true a done ``` **Почему работает:** Node.js сразу добавляет модуль в кеш с `loaded: false`. Когда `b` делает `require('./a')`, он получает частично заполненный `module.exports` (пустой на тот момент). После завершения `a`, его `module.exports` обновляется, но `b` уже получил ссылку на старый объект. **Вывод:** Избегайте круговых зависимостей или используйте lazy require внутри функций.

Вы используете require('./config') в 50 разных файлах. Сколько раз выполнится код config.js?

CJS <-> ESM Interoperability

Главный вопрос миграции: можно ли смешивать CommonJS и ES Modules? Короткий ответ: **да, но с ограничениями**. Node.js позволяет ESM импортировать CJS, но не наоборот (только через динамический `import()`). Это создаёт асимметрию, которую нужно понимать.

**Правила взаимодействия:** **1. ESM → CJS (работает):** ```js // lib.cjs (CommonJS) module.exports = { foo: 'bar' }; // app.mjs (ESM) import lib from './lib.cjs'; // ✅ Работает console.log(lib.foo); // 'bar' // Именованные импорты НЕ работают: import { foo } from './lib.cjs'; // ❌ ОШИБКА в большинстве случаев // Работает только если Node.js может статически проанализировать exports ``` **2. CJS → ESM (НЕ работает синхронно):** ```js // lib.mjs (ESM) export const foo = 'bar'; // app.cjs (CommonJS) const lib = require('./lib.mjs'); // ❌ ОШИБКА: ERR_REQUIRE_ESM // require() - синхронный, ESM - асинхронный. Несовместимо! // Решение: динамический import() (async () => { const lib = await import('./lib.mjs'); // ✅ Работает console.log(lib.foo); })(); ```

**Named exports из CommonJS:** Node.js пытается статически проанализировать CommonJS модули и создать named exports для ESM. Но это работает только для простых случаев (`exports.foo = ...`). Если exports создаётся динамически, доступен только default import.

Миграционный путь: hybrid package

**Задача:** У вас библиотека на CommonJS, нужно поддержать ESM без breaking changes. **Решение (рекомендованное Node.js):** **1. Билд в оба формата:** ```bash /dist /cjs index.js # CommonJS /esm index.mjs # ES Module ``` **2. package.json с "exports":** ```json { "type": "commonjs", "main": "./dist/cjs/index.js", "exports": { ".": { "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.js" } } } ``` **3. Билд-скрипт (TypeScript пример):** ```bash # ESM tsc --module esnext --outDir dist/esm renameext dist/esm/**/*.js .mjs # CJS tsc --module commonjs --outDir dist/cjs ``` **Результат:** - ESM проекты: `import lib from 'your-lib'` → загружает .mjs - CJS проекты: `const lib = require('your-lib')` → загружает .js - Один модуль, разные entry points, state разделён корректно

ESM - просто новый синтаксис для модулей. Можно конвертировать require() в import и всё заработает.

ESM и CommonJS - разные системы с разными моделями загрузки (статическая vs динамическая, асинхронная vs синхронная). Миграция требует понимания алгоритма разрешения, кеширования, interop ограничений и изменения build pipeline.

Многие разработчики думают, что миграция - это просто замена синтаксиса: `const foo = require('foo')` → `import foo from 'foo'`. Но на практике возникают проблемы: 1. **Top-level await** блокирует зависимые модули 2. **Динамические импорты** (`require(variable)`) нужно переписывать на `import()` 3. **Pure ESM пакеты** (node-fetch v3+, chalk v5+) ломают CJS проекты 4. **Dual package hazard** дублирует state 5. **Расширения файлов** становятся обязательными Миграция - это архитектурное изменение, а не рефакторинг синтаксиса. Нужно менять package.json, build конфигурацию, понимать module resolution, тестировать interop сценарии.

Ключевые идеи

  • **CommonJS (require/module.exports):** Синхронная, динамическая система. Модули выполняются в runtime, кешируются в `require.cache`, оборачиваются в функцию. Можно использовать вычисляемые пути, условные импорты. Идеально для Node.js, не работает в браузерах без бандлера.
  • **ES Modules (import/export):** Асинхронная, статическая система. Граф зависимостей строится до выполнения кода (tree-shaking, статические ошибки). Live bindings вместо копий. Расширения обязательны. Работает и в браузерах, и в Node.js. Будущее JavaScript.
  • **Module Resolution:** Сложный алгоритм с проверкой package.json "exports", поиском в node_modules иерархии, пробой расширений. "type" field в package.json определяет формат .js файлов. .mjs всегда ESM, .cjs всегда CommonJS.
  • **Кеширование:** Модули загружаются один раз, результат кешируется по абсолютному пути. Автоматические синглтоны. Циркулярные зависимости разрешаются через частичную загрузку. В ESM кеш недоступен напрямую.
  • **Interop:** ESM может импортировать CJS (default import всегда, named иногда). CJS НЕ может require() ESM (только async import()). Dual packages требуют "exports" для избежания дубликации state. Миграция на ESM — архитектурное изменение, не просто замена синтаксиса.

Связанные темы

Module System тесно связан с другими аспектами Node.js:

  • Event Loop — ESM загрузка асинхронная - происходит через Event Loop. Top-level await в ESM может блокировать весь граф зависимостей.
  • V8 Engine — V8 парсит модули и строит граф зависимостей. Static imports позволяют V8 оптимизировать загрузку через speculativ parsing.
  • Worker Threads — Каждый Worker имеет свой module cache. Shared modules между workers требуют явной передачи через Worker constructor или worker_threads API.

Вопросы для размышления

  • Ваш проект использует CommonJS. Одна из зависимостей обновилась до pure ESM. Какие стратегии миграции доступны? В каком порядке вы бы их применили?
  • Почему статический анализ ESM полезен для оптимизаций (tree-shaking, bundling), но ограничивает гибкость (нет условных импортов на top-level)?
  • Вы публикуете библиотеку, которая должна работать и в CommonJS, и в ESM проектах. Как настроить package.json "exports" чтобы избежать dual package hazard?

Связанные уроки

  • comp-01-intro
Module System: CommonJS vs ESM

0

1

Войти

Можно ли в CommonJS файле (app.cjs) сделать require('./module.mjs')?