CommonJS — первая модульная система, изначально встроенная в Node.js. Реализация CommonJS в Node.js соответствует спецификации CommonJS с добавлением некоторых пользовательских расширений.

Подытожим две основные концепции спецификации CommonJS:

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

Этой информации пока достаточно; мы узнаем больше деталей и некоторые нюансы спецификации CommonJS в следующих нескольких статьях.

САМОДЕЛЬНЫЙ ЗАГРУЗЧИК МОДУЛЕЙ

Чтобы понять, как CommonJS работает в Node.js, давайте создадим аналогичную систему с нуля. Следующий код создает функцию, которая имитирует подмножество функций исходной функции require() Node.js.

Давайте начнем с создания функции, которая загружает содержимое модуля, помещает его в частную область и оценивает его:

function loadModule (filename, module, require) {
 const wrappedSrc =
 `(function (module, exports, require) {
 ${fs.readFileSync(filename, 'utf8')}
 })(module, module.exports, require)`
 eval(wrappedSrc)
}

Исходный код модуля, по сути, заключен в функцию, как это было в раскрывающемся шаблоне модуля. Отличие здесь в том, что мы передаем в модуль список переменных, в частности, module, exports и require. Обратите внимание, как аргумент exports функции-обертки инициализируется содержимым module.exports, так как мы поговорим об этом позже.

Еще одна важная деталь, о которой следует упомянуть, заключается в том, что мы используем readFileSync для чтения содержимого модуля. Хотя обычно не рекомендуется использовать синхронную версию API файловой системы, здесь это имеет смысл. Причина этого в том, что загрузка модулей в CommonJS является преднамеренно синхронной операцией. Такой подход гарантирует, что если мы импортируем несколько модулей, они (и их зависимости) будут загружены в правильном порядке. Подробнее об этом аспекте мы поговорим позже.

Давайте теперь реализуем функцию require():

function require (moduleName) {
 console.log(`Require invoked for module: ${moduleName}`)
 const id = require.resolve(moduleName) // (1)
 if (require.cache[id]) { // (2)
 return require.cache[id].exports
 }
 // module metadata
 const module = { // (3)
 exports: {},
 id
 }
 // Update the cache
 require.cache[id] = module // (4)
 // load the module
 loadModule(id, module, require) // (5)
 // return exported variables
 return module.exports // (6)
}
require.cache = {}
require.resolve = (moduleName) => {
 /* resolve a full module id from the moduleName */
}

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

То, что делает наша самодельная модульная система, объясняется следующим образом:

  1. В качестве входных данных принимается имя модуля, и самое первое, что мы делаем, — это разрешаем полный путь к модулю, который мы называем id. Эта задача делегируется require.resolve() , которая реализует определенный алгоритм разрешения (мы поговорим о нем позже).
  2. Если модуль уже был загружен в прошлом, он должен быть доступен в кеше. Если это так, мы просто вернем его немедленно.
  3. Если модуль никогда ранее не загружался, мы настраиваем среду для первой загрузки. В частности, мы создаем объект модуля, который содержит свойство экспорта, инициализированное пустым литералом объекта. Этот объект будет заполнен кодом модуля для экспорта его общедоступного API.
  4. После первой загрузки объект модуля кэшируется.
  5. Исходный код модуля считывается из его файла, и код оценивается, как мы видели ранее. Мы предоставляем модулю только что созданный объект модуля и ссылку на функцию require(). Модуль экспортирует свой общедоступный API, манипулируя или заменяя объект module.exports.
  6. Наконец, вызывающему объекту возвращается содержимое module.exports , представляющее общедоступный API модуля.

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

ОПРЕДЕЛЕНИЕ МОДУЛЯ

Глядя на то, как работает наша пользовательская функция require(), мы теперь должны понять, как определить модуль. Следующий код дает нам пример:

// load another dependency
const dependency = require('./anotherModule')
// a private function
function log() {
 console.log(`Well done ${dependency.username}`)
}
// the API to be exported for public use
module.exports.run = () => {
 log()
}

Важно помнить, что все внутри модуля является частным, если оно не назначено переменной module.exports. Затем содержимое этой переменной кэшируется и возвращается при загрузке модуля с помощью require() .

МОДУЛЬ.ЭКСПОРТ ПРОТИВ ЭКСПОРТ

Для многих разработчиков, которые еще не знакомы с Node.js, частым источником путаницы является разница между использованием exports и module.exports для предоставления общедоступного API. Код нашей пользовательской функции require() снова должен развеять все сомнения. Переменная exports — это просто ссылка на начальное значение module.exports. Мы видели, что такое значение по существу представляет собой простой литерал объекта, созданный до загрузки модуля.

Это означает, что мы можем присоединять новые свойства только к объекту, на который ссылается переменная exports, как показано в следующем коде:

exports.hello = () => {
 console.log('Hello')
}

Переназначение переменной exports не имеет никакого эффекта, поскольку не меняет содержимое module.exports. Он только переназначит саму переменную. Поэтому следующий код неверен:

exports = () => {
 console.log('Hello')
}

Если мы хотим экспортировать что-то отличное от литерала объекта, например, функцию, экземпляр или даже строку, мы должны переназначить module.exports следующим образом:

module.exports = () => {
 console.log('Hello')
}

ТРЕБУЕМАЯ ФУНКЦИЯ СИНХРОННАЯ

Очень важная деталь, которую мы должны принять во внимание, заключается в том, что наша самодельная функция require() является синхронной. На самом деле он возвращает содержимое модуля, используя простой прямой стиль, и никакого обратного вызова не требуется. Это верно и для оригинальной функции Node.js require(). Как следствие, любое присваивание module.exports также должно быть синхронным. Например, следующий код неверен и вызовет проблемы:

setTimeout(() => {
 module.exports = function() {...}
}, 100)

Синхронный характер require() имеет важные последствия для того, как мы определяем модули, поскольку он ограничивает нас в основном использованием синхронного кода во время определения модуля. Это одна из наиболее важных причин, по которой основные библиотеки Node.js предлагают синхронные API в качестве альтернативы большинству асинхронных.

Если нам нужны некоторые асинхронные шаги инициализации для модуля, мы всегда можем определить и экспортировать неинициализированный модуль, который инициализируется асинхронно позже. Однако проблема с этим подходом заключается в том, что загрузка такого модуля с помощью require() не гарантирует, что он готов к использованию.

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

РЕШАЮЩИЙ АЛГОРИТМ

Термин ад зависимостей описывает ситуацию, когда две или более зависимостей программы, в свою очередь, зависят от общей зависимости, но требуют разных несовместимых версий. Node.js элегантно решает эту проблему, загружая разные версии модуля в зависимости от того, откуда загружается модуль. Все достоинства этой функции связаны с тем, как менеджеры пакетов Node.js (такие как npm или yarn) организуют зависимости приложения, а также с алгоритмом разрешения, используемым в функции require().

Давайте теперь дадим краткий обзор этого алгоритма. Как мы видели, функция resolve() принимает имя модуля (которое мы будем называть moduleName) в качестве входных данных и возвращает полный путь к модулю. Затем этот путь используется для загрузки его кода, а также для уникальной идентификации модуля. Алгоритм разрешения можно разделить на следующие три основные ветви:

  • Файловые модули: если имя модуля начинается с / , это уже считается абсолютным путем к модулю и возвращается как есть. Если он начинается с ./ , тогда moduleName считается относительным путем, который рассчитывается, начиная с каталога требуемого модуля.
  • Основные модули. Если имя модуля не имеет префикса / или ./ , алгоритм сначала попытается выполнить поиск в основных модулях Node.js.
  • Модули пакета: если основной модуль, соответствующий moduleName , не найден, поиск продолжается путем поиска соответствующего модуля в первом найденном каталоге node_modules при переходе вверх по структуре каталогов. начиная с требуемого модуля. Алгоритм продолжает поиск соответствия, просматривая следующий каталог node_modules в дереве каталогов, пока не достигнет корня файловой системы.

Для модулей файлов и пакетов и файлы, и каталоги могут соответствовать moduleName. В частности, алгоритм попытается сопоставить следующее:

  • ‹имя_модуля›.js
  • ‹имя_модуля›/index.js
  • Каталог/файл, указанный в main свойстве ‹moduleName›/package.json

В каталоге node_modules менеджеры пакетов устанавливают зависимости каждого пакета. Это означает, что, основываясь на только что описанном алгоритме, каждый пакет может иметь свои собственные частные зависимости. Например, рассмотрим следующую структуру каталогов:

В предыдущем примере myApp, depB и depC все зависят от depA . Однако все они имеют свою собственную частную версию зависимости! Следуя правилам алгоритма разрешения, использование require(‘depA’) загрузит другой файл в зависимости от модуля, который его требует, например:

  • Вызов require(‘depA’) из /myApp/foo.js загрузит /myApp/node_modules/depA/index.js
  • Вызов require('depA') из /myApp/node_modules/depB/bar.js загрузит /myApp/node_modules/depB/node_modules/depA/index. js
  • Вызов require('depA') из /myApp/node_modules/depC/foobar.js загрузит /myApp/node_modules/depC/node_modules/depA/index. js

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

КЭШ МОДУЛЯ

Каждый модуль загружается и оценивается только в первый раз, когда он требуется, поскольку любой последующий вызов require() просто вернет кешированную версию. Это должно быть ясно, если взглянуть на код нашей самодельной функции require(). Кэширование имеет решающее значение для производительности, но оно также имеет некоторые важные функциональные последствия:

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

Кэш модуля доступен через переменную require.cache, поэтому при необходимости к нему можно получить прямой доступ. Обычный вариант использования — сделать недействительным любой кешированный модуль, удалив относительный ключ в переменной require.cache. Эта практика может быть полезна во время тестирования, но очень опасна при обычных обстоятельствах.

КРУГОВЫЕ ЗАВИСИМОСТИ

Многие считают круговые зависимости внутренней проблемой дизайна, но это то, что может произойти в реальном проекте, поэтому нам полезно знать, по крайней мере, как это работает с CommonJS. Если мы снова посмотрим на нашу самодельную функцию require(), мы сразу же увидим, как она может работать и каковы ее предостережения.

Но давайте вместе рассмотрим пример, чтобы увидеть, как CommonJS ведет себя при работе с циклическими зависимостями. Предположим, у нас есть сценарий, представленный на следующем рисунке.

Модуль main.js требует a.js и b.js . В свою очередь, a.js требует b.js . Но b.js также зависит от a.js! Очевидно, что здесь у нас есть круговая зависимость, поскольку для модуля a.js требуется модуль b.js, а для модуля b.js требуется модуль a.js. . Давайте посмотрим на код этих двух модулей:

  • Модуль a.js :
exports.loaded = false
const b = require('./b')
module.exports = {
 b,
 loaded: true // overrides the previous export
}
  • Модуль b.js :
exports.loaded = false
const a = require('./a')
module.exports = {
 a,
 loaded: true
}

Теперь давайте посмотрим, как эти модули требуются для main.js:

const a = require('./a')
const b = require('./b')
console.log('a ->', JSON.stringify(a, null, 2))
console.log('b ->', JSON.stringify(b, null, 2))

Если мы запустим main.js , мы увидим следующий вывод:

a -> {
 "b": {
 "a": {
 "loaded": false
 },
 "loaded": true
 },
 "loaded": true
}
b -> {
 "a": {
 "loaded": false
 },
 "loaded": true
}

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

Чтобы подробнее понять, что происходит за кулисами, давайте шаг за шагом проанализируем, как интерпретируются разные модули и как по ходу меняется их локальная область видимости:

Шаги следующие:

  1. Обработка начинается в main.js , для чего немедленно требуется a.js.
  2. Первое, что делает модуль a.js, — устанавливает для экспортируемого значения с именем loaded значение false.
  3. На данный момент для модуля a.js требуется модуль b.js.
  4. Как и a.js , первое, что делает модуль b.js, — это устанавливает для экспортируемого значения с именем loaded значение false. эм>
  5. Теперь для b.js требуется a.js (цикл).
  6. Поскольку a.js уже был пройден, его текущее экспортированное значение немедленно копируется в область действия модуля b.js.
  7. Наконец, модуль b.js изменяет значение loaded на true.
  8. Теперь, когда b.js полностью выполнен, элемент управления возвращается к a.js, который теперь содержит копию текущего состояния модуля b.js<. /em> в своей области.
  9. Последним шагом модуля a.js является установка для его загруженного значения true.
  10. Модуль a.js теперь полностью выполнен, и управление возвращается к main.js , который теперь имеет копию текущего состояния модуля a.js в своей внутренней области.
  11. main.js требует b.js , который немедленно загружается из кеша.
  12. Текущее состояние модуля b.js копируется в область видимости модуля main.js, где мы наконец можем увидеть полную картину состояния каждого модуля.

Как мы уже говорили, проблема здесь в том, что модуль b.js имеет частичное представление модуля a.js , и это частичное представление распространяется, когда b. js требуется в main.js . Такое поведение должно пробудить интуицию, которую можно подтвердить, если мы поменяем порядок, в котором два модуля требуются в main.js. Если вы действительно попробуете это сделать, вы увидите, что на этот раз модуль a.js получит неполную версию b.js.

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

В других статьях мы увидим, как ESM может более эффективно справляться с циклическими зависимостями. Между тем, если вы используете CommonJS, будьте очень осторожны с таким поведением и тем, как оно может повлиять на ваше приложение.

В следующей статье мы обсудим некоторые шаблоны для определения модулей в Node.js.