Работа с модулями в Node.JS

Модуль — часть кода (пространство имен, класс, метод, блок кода), которая инкапсулирует детали реализации и предоставляет открытый API для использования другим кодом. Инкапсуляция подразумевает сокрытие внутренней структуры данных и реализации методов объекта от остальной программы. Однако при этом другим объектам доступен интерфейс объекта, через который осуществляется всё взаимодействие с ним.

По сути, модуль — это блок кода или инструкция (statement), которая вызывается:

  • прямо, при «запуске» файла программистом, или
  • косвенно, в результате импорта другим модулем.

Это привело к появлению в JS сначала паттерна программирования «Модуль», а затем и отдельных форматов: CommonJS, AMD, UMD, и специальных инструментов для работы с ними.

В настоящее время рекомендуется авторам пакетов для Node.js проектов использовать либо полностью CommonJS, либо полностью ES модули.

Модули CommonJS в Node.js

Подробнее в статье Node.js, TC-39 и модули

С точки зрения структуры модуль CommonJS — это часть JavaScript-кода, которая экспортирует определенные переменные, объекты или функции, делая их доступными для любого зависимого кода.

Для поддержки CommonJS необходима реализация module.exports и require().

С CommonJS в каждом JavaScript файле модуль хранится в своём собственном уникальном контексте (так же, как и в замыканиях). В этом пространстве мы используем объект module.exports чтобы экспортировать модули, и require чтобы подключить их.

Код модуля CommonJS состоит из двух частей:

  1. module.exports содержит объекты, которые модуль хочет сделать доступными;
  2. функция require() используется для импорта других модулей.
Подробнее о module.exports

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

Можно использовать более короткую запись exports, которая является ссылкой на module.exports, например:

ВАЖНО! Т.к. результат работы модуля можно вернуть только через свойство module.exports, то если переменной exports присвоить другое значение, она уже не будет ссылаться на module.exports и модуль ничего и не вернет.

[свернуть]

Пример модуля в формате CommonJS:

Пример кода, использующего модуль:

Ещё пример

Файл index.html

Файл index.js

Файл plus.js

Введя в командной строке node index вы увидите результат работы кода: строку "Hello, world!" и число 12.

[свернуть]
ВАЖНО! Работа CommonJS в браузере

CommonJS использует server-first подход и модули загружаются синхронно: если у нас есть несколько модулей, которые нам нужно подключить, они будут загружаться один за другим. Это затрудняет написание браузерного JavaScript, т.к. на получение модуля из интернета уходит намного больше времени, чем на получение модуля с жёсткого диска. Пока скрипт загружает модуль, браузер блокируется пока не закончится загрузка. 

Формат модуля CommonJS не оптимален для браузера (браузер может выдавать ошибку). Загрузчик RequireJS реализует формат AMD, что обеспечивает соответствие CommonJS среде браузера (поддерживает асинхронную загрузку). Используя загрузчик RequireJS на сервере Node, вы можете использовать один формат для всех своих модулей, независимо от того, работают ли они на стороне сервера или в браузере.

Таким образом, вы можете сохранить преимущества скорости и простую отладку, которую получаете с помощью RequireJS в браузере.

[свернуть]

В CommonJS наибольшее внимание было уделено спецификации модулей, которые в конечном итоге были реализованы в Node.JS.

Вы можете явно отметить свой код как CommonJS следующими признаками:

  1. файлы с расширением .cjs;
  2. файлы с расширением .js или без расширения вообще, при условии что ближайший родительский package.json содержит значение "type": "commonjs";
  3. код, переданный через аргумент --eval или STDIN c явным флагом --input-type=commonjs.
Пример файла package.json

[свернуть]

Модули ECMAScript 6 в Node.js

Нативная система модулей в JavaScript добавилась в спецификации ECMAScript 6.

Модуль обычно содержит класс или библиотеку с функциями. В ES6 соблюдается соотношение: один файл — один модуль. Каждый модуль имеет отдельную область видимости (Lexical environment) — т. е. все объявления переменных, функций и классов не будут доступны за пределами модуля (файла), если не экспортированы явно. На верхнем уровне модуля (т. е. вне других инструкций и выражений)  используются операторы:

  • import - для импорта других модулей и их экспортируемых сущностей, и
  • export - для экспорта собственных сущностей модуля (используя принадлежащий модулю неявный объект [[Exports]], в котором хранятся ссылки на все экспортируемые сущности, ключом к которым является идентификатор сущности, например, имя переменной).

Оператор export добавляет в [[Exports]] ссылку (или binding, привязку) на объявленную сущность. В случае экспорта по умолчанию (export default) именованной функции или класса — они будут объявлены в области видимости модуля, а [[Exports]].default будет ссылкой на эту сущность.

Оператор import получает объект [[Exports]] импортируемого модуля, находит в этом объекте указанные для импорта свойства, а затем создаёт привязки идентификаторов.

Node.js будет обрабатывать код как ES-модули в следующих случаях:

  1. файлы с расширением .mjs;
  2. файлы с расширением .js или без расширения вообще, при условии что ближайший к ним родительский package.json содержит значение "type": "module";
  3. код, переданный через аргумент --eval или STDIN, вместе с флагом --input-type=module;

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

Пример файла package.json

[свернуть]
Синтаксис оператора import

Синтаксис import:

  1. Указание файла или пакета:
    • относительный URL: "./file.mjs";
    • абсолютный URL c file://: "file:///src/app/file.mjs";
    • имя пакета: "es-module-package";
    • путь до файла внутри пакета: "es-module-package/lib/file.mjs".
  2. Указание импортируемых значений или объектов:
    • дефолтные значения: import _ from "es-module-package";
    • именованные значения: import { shuffle } from "es-module-package";
    • как одно пространство имен (namespace): import * as fs from "fs".

[свернуть]

Особенности оператора import:

  1. Импорты, которые указывают на CommonJS код (то есть весь текущий JavaScript, написанный для Node.js с использованием require и module.exports), могут использовать только дефолтный вариант import _ from "commonjs-package".
  2. Фигурные скобки, в которые заключаются импортируемые (или экспортируемые) сущности не являются литералом объекта.
  3. Конструкция import«всплывает» наверх модуля.
  4. import хранит именно ссылки на значения, экспортированные из внешнего модуля (это можно использовать как замыкания).

Особенности оператора export:

  1. Экспорт модулей ES6 определяется лексически, т.е. экспортируемые сущности определяются, когда анализируется код модуля JavaScript, до его фактического исполнения.
  2. Экспортируемые переменные должны быть определены.
Подробнее о работе операторов export и import

Подробнее в статье Node.js, TC-39 и модули

Например, рассмотрим следующий простой ECMAScript модуль:

Во время анализа этого кода (но до его фактического исполнения) создается внутренняя структура, называемая Module Record. Внутри этой структуры, помимо других ключевых данных, находится также и статический список сущностей, экспортируемых модулем. Они идентифицируются парсером, который ищет использование ключевого слова export. Определение, какие сущности экспортируются с помощью модуля ES6, происходит перед исполнением модуля. Т.е. указанный список сущностей в Module Record ссылается на сущности, которые, по сути, еще не существуют.

Только после того, как будет создана Module Record, будет фактически исполнен код модуля.

Когда код импортирует сущность из модуля ES6, используется оператор import:

Этот оператор представляет собой лексический оператор, используемый для установления связи между импортирующим скриптом и модулем «foo» при анализе кода. Как указывает современная спецификация ECMAScript модулей, эта связь должна быть проверена до какого-либо исполнения кода — это означает, что реализация должна убедиться, что символ «m» действительно экспортируется «foo» перед исполнением JavaScript файла.

[свернуть]
Примеры использования оператора import

Запуск модуля без импорта чего-либо:

Импорт одной сущности (переменной, объекта и т.д.):

Импорт нескольких сущностей (переменных, объектов и т.д.):

Переименование при импорте:

Импорт всей функциональности модуля в виде объекта:

Замечание: явное перечисление при импорте именования импортируемых сущностей позволяет:

  1. получить более короткие имена функций (вместо obj.result можно использовать result);
  2. сделать код более понятным (увидеть, что именно и где используется, что упрощает поддержку и рефакторинг кода).

[свернуть]
Примеры использования оператора export

Экспортируемые переменные должны быть определены.

Экспорт до объявления сущности (переменной, функции или класса):

Замечание: руководства по стилю кода в JavaScript не рекомендуют ставить ; после объявлений функций или классов, поэтому в конце export class и export function точка с запятой может не ставиться.

Экспорт отдельно от объявления сущности (переменной, функции или класса):

Переименование при экспорте:

Экспорт по умолчанию (export default):

Так как в ES6 соблюдается соотношение "один файл — один модуль" (который объявляет (выполняет) что-то одно), то в этом случае удобно использовать специальный синтаксис export default («экспорт по умолчанию»).

или

При этом для импорта используется запись импортируемого объекта без фигурных скобок:

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

Так как в файле может быть максимум один export default, то экспортируемая сущность не обязана иметь имя, например:

или

Замечание: экспорт по умолчанию в момент инициализации переменной или до её инициализации приведет к ошибке:

[свернуть]
Реэкспорт (экспорт импортированной сущности)

Синтаксис реэкспорта export ... from ... позволяет сразу же экспортировать сущность, импортированную из другого модуля, например:

[свернуть]
Пример с явным указанием типа модулей в теге скрипта

Файл index.html

Файл index.js

Файл plus.js

Введя в командной строке node index , вы увидите результат работы кода: строку "Hello, world!" и число 12.

[свернуть]
Пример с явным указанием типа модулей в package.json

Файл package.json

Файл index.html

Файл index.js

Файл plus.js

Введя в командной строке node index , вы увидите результат работы кода: строку "Hello, world!" и число 12.

[свернуть]
Разбор примера

Это моё видение работы импорта/экспорта модулей ES6 при кольцевании, на истину не претендую, буду рад замечаниям или поправкам.

Файл index.html:

Файл my_export.js:

Файл my_import.js:

Фактически, указанный пример реализует кольцевую зависимость между модулями. Запуск приведенного выше кода вернет ошибку ReferenceError: Cannot access 'my_String' before initialization. Разберем почему.

  1. Точка входа в приложение - модуль my_import.js, выполнение начинается с него, создается Module Record, после чего:
    • конструкция import log from "./my_export.js"; "всплывает" наверх (в случае, если размещена ниже по коду);
    • import log не может получить ссылку на [[Exports]].log (т.к. экспорт ещё не отработал);
    • импорт запускает на исполнение модуль my_export.js;
  2. В модуле my_export.js создается Module Record, после чего:
    • всплывает объявление функции function log() {};
    • конструкция import { my_String } from './my_import.js'; не может получить ссылку на [[Exports]].my_String  и запускает выполнение файла my_import.js как экспортирующего модуля (далее отсюда, после выполнения оставшегося кода модуля, переход к п.3);
    • программа пытается вычислить let sum_String = my_String + ",  world"; , что приводит к  ошибке вследствие того, что my_String ещё не инициализирована (а только объявлена). 
    • экспортируемая по умолчанию (export default) функция log() объявляется в области видимости модуля; [[Exports]].default становится ссылкой на log(), доступной для импортирующего (принимающего) модуля my_import.js;
  3. В модуле my_import.js (продолжается выполнение пункта 1):
    • объявляется (но пока не инициализируется до обращения к ней) экспортируемая переменная let my_String;
    • оператор export добавляет в [[Exports]] ссылку (или binding, привязку) на экспортируемую переменную my_String (объявленную, но не инициализированную переменную), доступную для импортирующего (принимающего) модуля my_export.js;
    • вызывается импортированная из my_export.js функция log(), при этом контекст вызванной функции - my_export.js (т.е. тот файл, из которого она экспортирована). 

Если let sum_String = my_String + ",  world"; переместить в функцию log(), то ошибка пропадет, т.к. my_String будет готова к работе при более позднем вызове функции log().

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

Исправленный файл my_export.js:

[свернуть]

Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.