CommonJS: формат для Node.js

Назначение CommonJS

CommonJS — спецификация модульной системы, разработанная для серверной среды JavaScript задолго до появления стандартных ES-модулей. На протяжении многих лет CommonJS являлся основным способом организации кода в Node.js и до сих пор широко используется в существующих проектах, библиотеках и инструментах сборки.

Главная задача CommonJS заключается в разделении приложения на независимые модули, каждый из которых содержит собственную область видимости и может экспортировать необходимые данные наружу.

Основные возможности:

  • импорт функциональности из других файлов;
  • экспорт функций, классов, объектов и констант;
  • изоляция переменных между модулями;
  • повторное использование кода;
  • упрощение сопровождения крупных проектов.

В экосистеме Node.js большинство старых пакетов распространяется именно в формате CommonJS.


Базовые принципы работы

Модуль CommonJS представляет собой обычный JavaScript-файл.

Для экспорта используются:

module.exports

или

exports

Для импорта используется функция:

require()

Структура может выглядеть следующим образом:

project/
├── app.js
├── math.js
└── logger.js

Экспорт через module.exports

Наиболее распространённый способ экспорта — присвоение значения объекту module.exports.

Файл:

// math.js

function sum(a, b) {
    return a + b;
}

module.exports = sum;

Использование:

// app.js

const sum = require("./math");

console.log(sum(5, 3));

Результат:

8

В данном случае модуль экспортирует единственную функцию.


Экспорт нескольких значений

Чаще требуется экспортировать несколько сущностей одновременно.

// math.js

function sum(a, b) {
    return a + b;
}

function multiply(a, b) {
    return a * b;
}

module.exports = {
    sum,
    multiply
};

Импорт:

const math = require("./math");

console.log(math.sum(2, 3));
console.log(math.multiply(2, 3));

Либо с деструктуризацией:

const { sum, multiply } = require("./math");

console.log(sum(2, 3));
console.log(multiply(2, 3));

Объект exports

Node.js создаёт сокращённую ссылку на module.exports:

exports

Пример:

exports.sum = function(a, b) {
    return a + b;
};

exports.multiply = function(a, b) {
    return a * b;
};

Импорт:

const math = require("./math");

console.log(math.sum(10, 5));

Фактически код эквивалентен:

module.exports.sum = ...
module.exports.multiply = ...

Разница между exports и module.exports

Это один из самых важных моментов при работе с CommonJS.

Изначально:

exports === module.exports

Однако присваивание нового значения ломает связь.

Правильно:

exports.sum = sum;

Неправильно:

exports = sum;

В таком случае экспорт не сработает.

Если требуется экспортировать одно значение целиком, необходимо использовать:

module.exports = sum;

Пример ошибки:

exports = {
    sum
};

Результат:

{}

Потому что Node.js экспортирует именно module.exports.


Импорт через require()

Функция require() загружает модуль и возвращает экспортированное значение.

Импорт локального файла:

const math = require("./math");

Импорт из родительской директории:

const config = require("../config");

Импорт встроенного модуля Node.js:

const fs = require("fs");

Импорт стороннего пакета:

const express = require("express");

Механизм поиска модулей

При вызове:

require("./utils")

Node.js выполняет поиск в определённом порядке.

Сначала:

utils.js

Затем:

utils.json

После этого:

utils.node

Если путь указывает на каталог:

utils/

Node.js ищет:

utils/package.json

и поле:

{
  "main": "index.js"
}

Если файл не найден, проверяется:

utils/index.js

Кэширование модулей

Каждый модуль загружается только один раз.

Файл:

// counter.js

let count = 0;

count++;

console.log("Module loaded");

module.exports = count;

Использование:

const a = require("./counter");
const b = require("./counter");

Вывод:

Module loaded

Сообщение появляется один раз, поскольку результат сохраняется в кэше.


Проверка кэша

Кэш доступен через:

require.cache

Просмотр:

console.log(require.cache);

Удаление конкретного модуля:

delete require.cache[
    require.resolve("./math")
];

Следующий вызов:

require("./math");

загрузит файл заново.


Внутреннее устройство модуля

Перед выполнением Node.js автоматически оборачивает код файла в функцию.

Упрощённо:

(function(
    exports,
    require,
    module,
    __filename,
    __dirname
) {

    // код файла

});

Благодаря этому каждый модуль имеет собственную область видимости.

Переменные одного файла не становятся глобальными.


Объект module

Каждый CommonJS-модуль получает объект module.

Пример:

console.log(module);

Некоторые полезные свойства:

module.id
module.path
module.filename
module.loaded
module.parent
module.children
module.exports

Наиболее важным является:

module.exports

Именно он определяет экспортируемое значение.


Переменная __filename

Содержит абсолютный путь к текущему файлу.

Пример:

console.log(__filename);

Результат:

C:\project\app.js

Используется для получения расположения текущего модуля.


Переменная __dirname

Содержит абсолютный путь к каталогу текущего файла.

Пример:

console.log(__dirname);

Результат:

C:\project

Часто применяется для работы с файлами.

const path = require("path");

const filePath =
    path.join(__dirname, "data.json");

Циклические зависимости

Циклическая зависимость возникает, когда два модуля импортируют друг друга.

Пример:

// a.js

const b = require("./b");

module.exports = {
    value: "A"
};
// b.js

const a = require("./a");

module.exports = {
    value: "B"
};

Node.js способен обработать такую ситуацию благодаря механизму кэширования, однако часть экспортируемых данных может оказаться недоступной на момент загрузки.

Поэтому циклические зависимости считаются плохой архитектурной практикой.


CommonJS и синхронная загрузка

Особенность CommonJS заключается в синхронной работе функции require().

const math = require("./math");

Выполнение программы останавливается до завершения загрузки модуля.

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

Для браузеров такой подход менее эффективен, поэтому позже появились ES-модули с поддержкой асинхронной загрузки.


CommonJS и ES Modules

С появлением стандарта ECMAScript возник новый формат модулей — ES Modules (ESM).

Сравнение синтаксиса:

CommonJS

const math = require("./math");

module.exports = {
    sum
};

ES Modules

import { sum } from "./math.js";

export { sum };

Основные отличия:

Возможность CommonJS ES Modules
Импорт require() import
Экспорт module.exports export
Загрузка Синхронная Асинхронная
Стандарт ECMAScript Нет Да
Анализ зависимостей Во время выполнения До выполнения
Tree Shaking Ограничен Полноценный

Поддержка CommonJS в Esbuild

Esbuild полностью поддерживает CommonJS и умеет:

  • собирать проекты CommonJS;
  • преобразовывать CommonJS в ESM;
  • преобразовывать ESM в CommonJS;
  • объединять зависимости в единый файл;
  • автоматически анализировать вызовы require().

Пример файла:

const fs = require("fs");

module.exports = function() {
    console.log("Hello");
};

Сборка:

esbuild app.js --bundle --outfile=bundle.js

Esbuild распознаёт CommonJS-модуль и корректно включает его в итоговый бандл.


Преобразование CommonJS в ESM

Исходный файл:

module.exports = {
    hello() {
        console.log("Hello");
    }
};

Команда:

esbuild app.js \
  --bundle \
  --format=esm \
  --outfile=app.mjs

Esbuild создаёт совместимый ESM-код.

Это особенно полезно при миграции старых проектов на современную модульную систему.


Преобразование ESM в CommonJS

Исходный код:

export function hello() {
    console.log("Hello");
}

Сборка:

esbuild app.js \
  --bundle \
  --format=cjs \
  --outfile=app.js

Результат становится пригодным для выполнения в среде Node.js, использующей CommonJS.


Tree Shaking и CommonJS

Одним из ограничений CommonJS является сложность статического анализа.

Например:

const moduleName = getModuleName();

const mod = require(moduleName);

Во время сборки невозможно заранее определить импортируемый модуль.

Из-за этого инструменты оптимизации работают менее эффективно.

ESM выглядит более предсказуемо:

import { sum } from "./math.js";

Поэтому при использовании CommonJS возможности tree shaking ограничены.

Esbuild всё равно выполняет множество оптимизаций, однако максимальная эффективность достигается при использовании ES Modules.


Формат CommonJS в современных проектах

Несмотря на активное распространение ES Modules, CommonJS продолжает играть важную роль:

  • большинство существующих npm-пакетов поддерживает CommonJS;
  • значительное количество серверных приложений Node.js написано на CommonJS;
  • многие инструменты сборки сохраняют совместимость с данным форматом;
  • миграция крупных кодовых баз на ESM может занимать значительное время.

По этой причине понимание принципов работы require(), exports и module.exports остаётся важным навыком при разработке приложений на Node.js и при использовании Esbuild для сборки, оптимизации и трансформации модульного кода.