Модуль — часть кода (пространство имен, класс, метод, блок кода), которая инкапсулирует детали реализации и предоставляет открытый 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 состоит из двух частей:
- module.exports содержит объекты, которые модуль хочет сделать доступными;
- функция require() используется для импорта других модулей.
В каждом модуле доступен объект module, который позволяет модулю вернуть результат своего исполнения (это может быть любой тип данных: объект, функция, строка и т.д.) через свойство exports (именно в него нужно присваивать все то, что вы хотите вернуть из модуля), например:
1 2 3 |
module.exports.foobar = foobar; // или module.exports.createUser = function () { return new User(); } |
Можно использовать более короткую запись exports, которая является ссылкой на module.exports, например:
1 2 3 |
exports.foobar = foobar; // или exports.createUser = function () { return new User(); } |
ВАЖНО! Т.к. результат работы модуля можно вернуть только через свойство module.exports, то если переменной exports присвоить другое значение, она уже не будет ссылаться на module.exports и модуль ничего и не вернет.
Пример модуля в формате CommonJS:
1 2 3 4 5 6 7 8 9 10 11 12 |
// COMMONJS определяем функцию, которую хотим экспортировать function foobar() { this.foo = function() { console.log("Hello foo"); }; this.bar = function() { console.log("Hello bar"); }; } // делаем ее доступной для других модулей module.exports.foobar = foobar; |
Пример кода, использующего модуль:
1 2 3 4 |
var foobar = require("./foobar").foobar, test = new foobar(); test.bar(); // 'Hello bar' |
Файл index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Modules</title> </head> <body> <h2>"Modules + Node.JS"</h2> <script src="index.js"></script> <script src="plus.js"></script> </body> </html> |
Файл index.js
1 2 3 4 5 |
res = require("./plus.js").res; // импорт результата из plus.js // собственный функционал index.js console.log("Hello, world!"); console.log(res); |
Файл plus.js
1 2 |
let res = 6 + 6; // некоторый функционал module.exports.res = res; // экспорт результата |
Введя в командной строке node index вы увидите результат работы кода: строку "Hello, world!" и число 12.
CommonJS использует server-first подход и модули загружаются синхронно: если у нас есть несколько модулей, которые нам нужно подключить, они будут загружаться один за другим. Это затрудняет написание браузерного JavaScript, т.к. на получение модуля из интернета уходит намного больше времени, чем на получение модуля с жёсткого диска. Пока скрипт загружает модуль, браузер блокируется пока не закончится загрузка.
Формат модуля CommonJS не оптимален для браузера (браузер может выдавать ошибку). Загрузчик RequireJS реализует формат AMD, что обеспечивает соответствие CommonJS среде браузера (поддерживает асинхронную загрузку). Используя загрузчик RequireJS на сервере Node, вы можете использовать один формат для всех своих модулей, независимо от того, работают ли они на стороне сервера или в браузере.
Таким образом, вы можете сохранить преимущества скорости и простую отладку, которую получаете с помощью RequireJS в браузере.
В CommonJS наибольшее внимание было уделено спецификации модулей, которые в конечном итоге были реализованы в Node.JS.
Вы можете явно отметить свой код как CommonJS следующими признаками:
- файлы с расширением .cjs;
- файлы с расширением .js или без расширения вообще, при условии что ближайший родительский package.json содержит значение "type": "commonjs";
- код, переданный через аргумент --eval или STDIN c явным флагом --input-type=commonjs.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "name": "modules", "version": "1.0.0", "description": "Module project", "main": "index.js", "type":"commonjs", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["module", "webpack"], "author": "", "license": "ISC" } |
Модули 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-модули в следующих случаях:
- файлы с расширением .mjs;
- файлы с расширением .js или без расширения вообще, при условии что ближайший к ним родительский package.json содержит значение "type": "module";
- код, переданный через аргумент --eval или STDIN, вместе с флагом --input-type=module;
Во всех остальных случаях код будет считаться CommonJS, поэтому целесообразно указывать тип модулей явным образом.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "name": "modules", "version": "1.0.0", "description": "Module project", "main": "index.js", "type":"module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["module", "webpack"], "author": "", "license": "ISC" } |
Синтаксис import:
- Указание файла или пакета:
- относительный URL: "./file.mjs";
- абсолютный URL c file://: "file:///src/app/file.mjs";
- имя пакета: "es-module-package";
- путь до файла внутри пакета: "es-module-package/lib/file.mjs".
- Указание импортируемых значений или объектов:
- дефолтные значения: import _ from "es-module-package";
- именованные значения: import { shuffle } from "es-module-package";
- как одно пространство имен (namespace): import * as fs from "fs".
Особенности оператора import:
- Импорты, которые указывают на CommonJS код (то есть весь текущий JavaScript, написанный для Node.js с использованием require и module.exports), могут использовать только дефолтный вариант import _ from "commonjs-package".
- Фигурные скобки, в которые заключаются импортируемые (или экспортируемые) сущности не являются литералом объекта.
- Конструкция import«всплывает» наверх модуля.
- import хранит именно ссылки на значения, экспортированные из внешнего модуля (это можно использовать как замыкания).
Особенности оператора export:
- Экспорт модулей ES6 определяется лексически, т.е. экспортируемые сущности определяются, когда анализируется код модуля JavaScript, до его фактического исполнения.
- Экспортируемые переменные должны быть определены.
Подробнее в статье Node.js, TC-39 и модули
Например, рассмотрим следующий простой ECMAScript модуль:
1 |
export const m = 1; |
Во время анализа этого кода (но до его фактического исполнения) создается внутренняя структура, называемая Module Record. Внутри этой структуры, помимо других ключевых данных, находится также и статический список сущностей, экспортируемых модулем. Они идентифицируются парсером, который ищет использование ключевого слова export. Определение, какие сущности экспортируются с помощью модуля ES6, происходит перед исполнением модуля. Т.е. указанный список сущностей в Module Record ссылается на сущности, которые, по сути, еще не существуют.
Только после того, как будет создана Module Record, будет фактически исполнен код модуля.
Когда код импортирует сущность из модуля ES6, используется оператор import:
1 |
import {m} from “foo”; |
Этот оператор представляет собой лексический оператор, используемый для установления связи между импортирующим скриптом и модулем «foo» при анализе кода. Как указывает современная спецификация ECMAScript модулей, эта связь должна быть проверена до какого-либо исполнения кода — это означает, что реализация должна убедиться, что символ «m» действительно экспортируется «foo» перед исполнением JavaScript файла.
Запуск модуля без импорта чего-либо:
1 |
import './file_name'; |
Импорт одной сущности (переменной, объекта и т.д.):
1 |
import { objResult } from "./plus.js"; |
Импорт нескольких сущностей (переменных, объектов и т.д.):
1 |
import { result, result_7, objResult } from "./plus.js"; |
Переименование при импорте:
1 |
import { result as res, result_7 as res_7 , objResult} from "./plus.js"; |
Импорт всей функциональности модуля в виде объекта:
1 2 3 4 5 |
import * as obj from "./plus.js"; // импорт функционала модуля plus.js как объекта obj console.log(obj.result); // 12 console.log(obj.result_7); // 14 console.log(obj.objResult); // {result: 12, result_7: 14} |
Замечание: явное перечисление при импорте именования импортируемых сущностей позволяет:
- получить более короткие имена функций (вместо obj.result можно использовать result);
- сделать код более понятным (увидеть, что именно и где используется, что упрощает поддержку и рефакторинг кода).
Экспортируемые переменные должны быть определены.
Экспорт до объявления сущности (переменной, функции или класса):
1 2 3 4 5 6 7 8 9 10 11 12 |
// экспорт массива export let user = ["Mary", "Serg", "Olga", "Alex"]; // экспорт константы export const YEAR = 2021; // экспорт класса export class User { constructor(name) { this.name = name; } } |
Замечание: руководства по стилю кода в JavaScript не рекомендуют ставить ; после объявлений функций или классов, поэтому в конце export class и export function точка с запятой может не ставиться.
Экспорт отдельно от объявления сущности (переменной, функции или класса):
1 2 3 4 5 6 7 8 9 |
let user = ["Mary", "Serg", "Olga", "Alex"]; const YEAR = 2021; class User { constructor(name) { this.name = name; } } export { user, YEAR, User }; // можно разместить оператор export выше (например, в начале файла) |
Переименование при экспорте:
1 |
export { user as myUser, YEAR, User }; |
Экспорт по умолчанию (export default):
Так как в ES6 соблюдается соотношение "один файл — один модуль" (который объявляет (выполняет) что-то одно), то в этом случае удобно использовать специальный синтаксис export default («экспорт по умолчанию»).
1 2 3 4 5 6 7 |
class User { constructor(name) { this.name = name; } } export default User; |
или
1 2 3 4 5 |
export default class User { constructor(name) { this.name = name; } } |
При этом для импорта используется запись импортируемого объекта без фигурных скобок:
1 |
import User from "./plus.js"; |
Технически в одном модуле может быть как экспорт по умолчанию, так и именованные экспорты, но на практике обычно их не смешивают. То есть, в модуле находятся либо именованные экспорты, либо один экспорт по умолчанию.
Так как в файле может быть максимум один export default, то экспортируемая сущность не обязана иметь имя, например:
1 2 3 4 5 |
export default class { constructor(name) { this.name = name; } } |
или
1 |
export default ["Mary", "Serg", "Olga", "Alex"]; |
Замечание: экспорт по умолчанию в момент инициализации переменной или до её инициализации приведет к ошибке:
1 |
export default let result = 6 + 6; // ошибка |
1 2 |
export default result; // ошибка let result = 6 + 6; |
Синтаксис реэкспорта export ... from ... позволяет сразу же экспортировать сущность, импортированную из другого модуля, например:
1 2 3 |
export { result } from "./plus.js"; export {default as User} from './plus.js'; // реэкспорт default с переименованием |
Файл index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Modules</title> </head> <body> <h2>"Modules + Node.JS"</h2> <script type="module" src="index.js"></script> // явно указываем тип модулей для браузера - ES6 (не для Node.js) <script type="module" src="plus.js"></script> // явно указываем тип модулей для браузера - ES6 не для Node.js) </body> </html> |
Файл index.js
1 2 3 |
import res from "./plus.js"; // импорт для модулей ES6 console.log("Hello, world!"); console.log(res); |
Файл plus.js
1 2 |
let res = 6 + 6; // некоторый функционал export default res; // экспорт результата для модулей ES6 |
Введя в командной строке node index , вы увидите результат работы кода: строку "Hello, world!" и число 12.
Файл package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "name": "modules", "version": "1.0.0", "description": "Module project", "main": "index.js", "type":"module", // явно указываем тип модулей - ES6 "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["module", "webpack"], "author": "", "license": "ISC" } |
Файл index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Modules</title> </head> <body> <h2>"Modules + Node.JS"</h2> <script src="index.js"></script> <script src="plus.js"></script> </body> </html> |
Файл index.js
1 2 3 |
import res from "./plus.js"; // импорт для модулей ES6 console.log("Hello, world!"); console.log(res); |
Файл plus.js
1 2 |
let res = 6 + 6; // некоторый функционал export default res; // экспорт результата для модулей ES6 |
Введя в командной строке node index , вы увидите результат работы кода: строку "Hello, world!" и число 12.
Это моё видение работы импорта/экспорта модулей ES6 при кольцевании, на истину не претендую, буду рад замечаниям или поправкам.
Файл index.html:
1 2 3 4 5 6 7 8 9 10 11 12 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Мои примеры</title> <script type="module" src="./my_import.js"></script> </head> <body> </body> </html> |
Файл my_export.js:
1 2 3 4 5 6 7 8 |
import { my_String } from './my_import.js'; let sum_String = my_String + ', world' export default function log() { console.log(my_String); console.log(sum_String); } |
Файл my_import.js:
1 2 3 4 5 |
import log from "./my_export.js"; export let my_String = "Hello"; log(); |
Фактически, указанный пример реализует кольцевую зависимость между модулями. Запуск приведенного выше кода вернет ошибку ReferenceError: Cannot access 'my_String' before initialization. Разберем почему.
- Точка входа в приложение - модуль my_import.js, выполнение начинается с него, создается Module Record, после чего:
- конструкция import log from "./my_export.js"; "всплывает" наверх (в случае, если размещена ниже по коду);
- import log не может получить ссылку на [[Exports]].log (т.к. экспорт ещё не отработал);
- импорт запускает на исполнение модуль my_export.js;
- В модуле 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;
- В модуле 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:
1 2 3 4 5 6 7 |
import { my_String } from "./my_import.js"; export default function log() { let sum_String = my_String + ", world"; console.log(my_String); console.log(sum_String); } |