Подробно о модулях в Node.js

Node.js использует 2 встроенных модуля для управления зависимостями модулей :

  • Модуль require служит для подключения модулей.
  • Модуль module служит для организации модулей.

Подключение модулей в Node делается примерно так :

const config = require('/path/to/file');

Когда Node вызывает функцию require(), то делает следующее :

  • Resolving: определяет абсолютный путь к файлу.
  • Loading: при загрузке определяется тип файла.
  • Wrapping: для файла создаётся своя область видимости. Объекты require и module появляются в локальной области видимости подключаемого файла.
  • Evaluating: выполнение загруженного кода.
  • Caching: кеширование. Когда во второй раз подключаем файл, то он выдаётся из кеша. Поэтому предыдущие шаги уже не выполняются.

Рассмотрим каждый из этих этапов подробнее.

Весь код буду располагать в папке c:\js>.

Рассмотрим объект module. Посмотрим на него в обычном REPL режиме :

information about module

В свойстве id обычно используется полный путь к файлу. В режиме REPL это просто <repl>.

Каждому модулю соответствует определённый файл, содержимое которого загружается когда мы его подключаем функцией require.

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

Когда мы запрашиваем модуль без указания пути к нему :

require('find-me');

Node будет искать файл find-me.js во всех путях указанных в module.paths, которые мы видели на скриншоте выше. Пути будут перебираться по порядку, начиная с папки node_modules которая находится в текущей директории.

Создадим папку node_modules в текущей директории и создадим в ней файл find-me.js такого создержания :

console.log('I am not lost');

Тогда require('find-me'); найдёт этот модуль.

c:\js>node
> require('find-me');
I am not lost
{}
>

Если существует другой файл find-me.js по другому пути из module.paths, например в node_modules который в родительской директории, то он не будет загружен. Загрузится тот который будет найден раньше, а искать его Node будет именно в том порядке в котором указаны пути в module.paths.

Модуль в виде папки

find-me может быть папкой в node_modules. В таком случае require('find-me') будет искать файл index.js в этой папке.

Файл index.js будет использован по умолчанию когда мы подключаем папку. Однако мы можем поменять это поведение с помощью файла package.json который расположим в папке с модулем. В package.json в свойстве main можно указать другое имя файла который будет загружен вместо index.js. По сути новая точка входа.

Относительные и абсолютные пути

Несмотря на то что по умолчанию модули подключаются из node_modules, их можно подключать из любой папки используя относительные пути (./ и ../) или абсолютные которые начинаются с /.

Если к примеру файл find-me.js находится в папке lib, то мы можем подключить его так :

require('./lib/find-me');

Отношения parent-child между файлами

Создадим файл lib/util.js :

console.log('In util', module);

И файл index.js

console.log('In index', module); require('./lib/util');

Теперь запускаем файл index.js :

c:\js>node index.js
In index Module {
  id: '.',
  exports: {},
  parent: null,
  filename: 'c:\\js\\index.js',
  loaded: false,
  children: [],
  paths: [ 'c:\\js\\node_modules', 'c:\\node_modules' ] }
In util Module {
  id: 'c:\\js\\lib\\util.js',
  exports: {},
  parent:  // ссылается на родительский модуль index
   Module {
     id: '.', // родительский модуль
     exports: {},
     parent: null,
     filename: 'c:\\js\\index.js',
     loaded: false,
     children: [ [Circular] ], 
     /* [Circular] это циклическая ссылка. Указывает на модуль lib/util. Здесь не показано истинное значение чтобы не попасть в бесконечный цикл */
     paths: [ 'c:\\js\\node_modules', 'c:\\node_modules' ] },
  filename: 'c:\\js\\lib\\util.js',
  loaded: false,
  children: [],
  paths:
   [ 'c:\\js\\lib\\node_modules',
     'c:\\js\\node_modules',
     'c:\\node_modules' ] }

Обратите внимание как главный модуль index (id: '.') сейчас указан в качестве родителя parent для модуля lib/util.

Можно даже подключить модуль index из lib/util. Получится циклическая зависимость модулей. Это разрешено. Чтобы разобраться с этим получше, сначала разберёмся с другими концепциями объекта module.

exports, module.exports, и синхронная загрузка модулей

В любом модуле есть специальный объект exports. Вы могли заметить его в листингах выше. Можно добавить любые атрибуты к этому объекту. К примеру :

// Добавим в начало файла lib/util.js
exports.id = 'lib/util';

// Добавим в начало файла index.js
exports.id = 'index';
Теперь запустим index.js и увидим эти атрибуты :
c:\js>node index.js
In index Module {
  id: '.',
  exports: { id: 'index' },
  parent: null,
  filename: 'c:\\js\\index.js',
  loaded: false,
  ... }
In util Module {
  id: 'c:\\js\\lib\\util.js',
  exports: { id: 'lib/util' },
  parent:
   Module {
     id: '.',
     exports: { id: 'index' },
     parent: null,
     filename: 'c:\\js\\index.js',
     loaded: false,
     ... },
   ... }

Немного сократил выдачу чтобы лучше было видно. Объект exports теперь имеет те атрибуты которые мы задавали в каждом из модулей. Можете использовать сколько угодно атрибутов, и можно заменить весь объект на чтонибудь другое. К примеру можно заменить его на функцию :

// Добавим это в index.js
module.exports = function() {};
c:\js>node index.js
In index Module {
  id: '.',
  exports: [Function],
     ... }
...

Обратите внимание что мы не написали

exports = function() {}
. Потомучто переменная exports в каждом модуле это всего лишь ссылка ( reference ) на module.exports. Если мы переопределим переменную exports, то мы потеряем эту ссылку и по сути просто создадим новую переменную, вместо того чтобы изменить module.exports, который на самом деле экспортирует свойства.

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

Поменяем файл index.js :

const UTIL = require('./lib/util');
console.log('UTIL:', UTIL);

Свойства из lib/util экспортируются в констранту UTIL :

c:\js>node index.js
In util Module {
  id: 'c:\\js\\lib\\util.js',
  exports: { id: 'lib/util' },
...
UTIL: { id: 'lib/util' }

Рассмотрим атрибут loaded который есть в каждом модуле. В листингах выше можно заметить что loaded каждый раз имел значение false. Модуль module использует этот атрибут для отслеживая какой модуль был загружен ( значение true ), а какой еще загружается. Мы можем к примеру увидеть что модуль index.js был полностью загружен когда проверим объект module на следующем цикле event loop, используя вызов setImmediate :

// Файл index.js
const UTIL = require('./lib/util');
setImmediate(() => {
  console.log('The index.js module object is now loaded!', module)
});
c:\js>node index.js
In util Module {
  id: 'c:\\js\\lib\\util.js',
  exports: { id: 'lib/util' },
  parent:
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: 'c:\\js\\index.js',
     loaded: false,
     children: [ [Circular] ],
     paths: [ 'c:\\js\\node_modules', 'c:\\node_modules' ] },
  filename: 'c:\\js\\lib\\util.js',
  loaded: false,
  children: [],
  paths:
   [ 'c:\\js\\lib\\node_modules',
     'c:\\js\\node_modules',
     'c:\\node_modules' ] }
The index.js module object is now loaded! Module {
  id: '.',
  exports: {},
  parent: null,
  filename: 'c:\\js\\index.js',
  loaded: true,
  children:
   [ Module {
       id: 'c:\\js\\lib\\util.js',
       exports: [Object],
       parent: [Circular],
       filename: 'c:\\js\\lib\\util.js',
       loaded: true,
       ... }

В отложенном запуске console.log видим что lib/util.js и index.js полностью загрузились.

Объект exports становится завершённым когда Node завершает загрузку модуля и делает пометку loaded: true. Весь процесс подключения и загрузки модуля происходит синхронно. Это означает что нам нельзя изменять exports асинхронно. К примеру нельзя делать так :

fs.readFile('/etc/passwd', (err, data) => {
  if (err) throw err;
  exports.data = data; // Will not work.
});

Круговая, кольцевая, циклическая зависимость модулей.

Что случится если модуль 1 запросит модуль 2, а 2-й при этом запрашивает 1-й ? Чтобы ответить на этот вопрос создадим 2 файла в папке lib, module1.js и module2.js и чтобы каждый из них запрашивал другой.

// lib/module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// lib/module2.js
const Module1 = require('./module1');
console.log('Module1 is partially loaded here', Module1);
Запускаем module1.js и видим следующее :
c:\js>node lib/module1.js
Module1 is partially loaded here { a: 1 }

Мы подключаем module2 до того как module1 был полностью загружен. Т.е. module2 запрашивает module1 до того как тот полностью загрузился. В объекте exports мы получим те свойства которые были определены до начала круговой зависимости. Видим только свойство a, т.к. b и c были экспортированы после подключения module2, который вывел на экран то что экспортирует module1.

Node делает это очень просто. Во время загрузки модуля, он выстраивает объект exports. Вы можете подключать модуль и не ждать когда он закончит загрузку, просто получите частичный exports с тем что в нём определено на данный момент.

JSON и C/C++ аддоны

Можно нативно подключать файлы JSON и аддоны на C++ всё тойже функцией require. Вам даже не надо указываеть расширение файла.

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

Подключение JSON файлов полезно в случае конфигурационных файлов. К примеру :

{
  "host": "localhost",
  "port": 8080
}

Подключается он так :

const { host, port } = require('./config');
console.log(`Server will run at http://${host}:${port}`);

Как писать аддоны на C++ можно почитать тут.

Код каждого модуля оборачивается в функцию

Чтобы понять как это работает, вспомните чем отличается exports и module.exports. exports это всеголишь ссылка на module.exports.

exports.id = 42; // Правильно
exports = { id: 42 }; // Неправильно
module.exports = { id: 42 }; // Правильно

Откуда же появляется объект exports, который виден глобально в пределах модуля ?

Вспомним как работают глобальные переменные в браузере. Когда мы объявляем :

var answer = 42; // Переменная становится видна во всех скриптах

В Node это происходит по другому. Когда мы объявляем переменную в одном модуле, в других модулях она не видна.

Перед компиляцией модуля, Node оборачивает весь код модуля в функцию, которую мы можем увидеть в свойстве wrapper объекта module.

c:\js>node
> require('module').wrapper
[ '(function (exports, require, module, __filename, __dirname) { ',
  '\n});' ]
>

Код модуля становится телом этой функции. Поэтому все переменные верхнего уровня в модуле имеют область видимости только в нём и не видны из других модулей.

У функции 5 аргументов : exports, require, module, __filename, и __dirname. Они только кажутся глобальными, но на самом деле у каждого модуля они свои. И получают свое значение когда Node запускает эту функцию враппер.

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

Также их можно увидеть через ключевое слово arguments.

// файл index.js
console.log(arguments)
c:\js>node index.js
{ '0': {},
  '1':
   { [Function: require]
     resolve: { [Function: resolve] paths: [Function: paths] },
     main:
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: 'c:\\js\\index.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache: { 'c:\js\index.js': [Module] } },
  '2':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: 'c:\\js\\index.js',
     loaded: false,
     children: [],
     paths: [ 'c:\\js\\node_modules', 'c:\\node_modules' ] },
  '3': 'c:\\js\\index.js',
  '4': 'c:\\js' }

Оборачивающая функция возвращает значение module.exports.

Это выглядит примерно так :

function (require, module, __filename, __dirname) {
  let exports = module.exports;
  // Здесь ваш код ...
  return module.exports;
}

Объект require

Объект который обычно используется как функция. Аргумент имя модуля или путь, и возвращает module.exports.

Её можно переопределить к примеру так :

require = function() {
  return { mocked: true };
}

require.resolve

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

require.main

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

if (require.main === module) {
  // файл запущен напрямую ( не был подключен из другого модуля )
}

require.cache

Модули кэшируются в объекте require.cache. Почистить кэш можно просто удалив ключ из этого объекта, и при следующем запросе модуля, он будет перезагружен. Однако это не работает на нативных аддонах, и перезагрузка вызовет ошибку.

Допустим у нас есть модуль такой :

let dep = {  
  created: Date.now()
};
module.exports = dep;

Если мы подключаем этот модуль несколько раз, то дата будет одной и тойже, т.к. экземпляр модуля берётся из кэша. По сути это как singleton.

 // файл index.js
let dep1 = require('./lib/dep');
setTimeout(function(argument) {
	let dep2 = require('./lib/dep');
	if(dep1.created === dep2.created){
	  console.log('даты совпадают'); // получится это
	}else{
	  console.log('даты разные');
	}
}, 50);

А теперь почистим кэш и посмотрим что получится :

// файл index.js
let dep1 = require('./lib/dep');
delete require.cache[require.resolve('./lib/dep')]; // Почистили кэш
setTimeout(function(argument) {
	let dep2 = require('./lib/dep');
	if(dep1.created === dep2.created){
	  console.log('даты совпадают');
	}else{
	  console.log('даты разные');  // получится это
	}
}, 50);