CommonJS, RequireJS, UMD: модули JavaScript простыми словами
Фронтенд в простом виде это HTML, JS и CSS файлы. Когда веб-сайты были простыми, а JS использовался лишь для обработки событий нажатия, все было нормально. Постепенно JS как язык становился более продвинутым, а логика фронтенд-приложений усложнялась. Появилась необходимость разбивать скрипты на отдельные модули.
Если вы, как и я, не понимаете, что вообще происходит, когда дело доходит до модулей JS, то эта серия статей вам поможет разобраться.
Почему в JS не было модулей из коробки?
В Python, например, модуль - это файл. Создадим hello.py со следующим кодом:
// hello.py def say_hello(name) print(f"Hello dear {name}")
Его можно импортировать в другой модуль:
//main.py import hello hello.say_hello('Dima')
Все просто и удобно. Не так ли? Но что там в JS? JS развивался как упрощенная версия Java для дизайнеров, поэтому многих вещей в языке не было, включая модулей, которые появились в стандарте лишь в 2015 году с выходом ES6. Лишь некоторое время назад большинство браузеров стали подддерживать ES-модули, но о них чуть позже.
Раньше в языке вся логика описывалась в одном файле. И все переменные и методы, объявленные внутри файла, были доступны остальным файлам. В самом простом виде есть index.html, куда мы можем вставлять тег script и писать JS-код прямо в верстке.
<!DOCTYPE html> <html lang="en"> <head> <title>Document</title> <div id='greetingElem'>hello</div> <script> document.querySelector('greetingElem').innerText = 'hello world'; </script> </head> <body> </body> </html>
Сможем ли мы так сделать аналог Facebook? Сможем, но тогда нам придется разом загружать весь код вебсайта при входе на страницу, а это мегабайты кода. Да и поддерживать такой сайт будет огромной проблемой.
Чтобы отделить верстку от кода, давайте создадим script.js файл.
//index_1.js var name = 'Boris'; function getNameLength() { return name.length; }
Постепенно сайт обрастает функциями, кода становится много, и держать всю логику в одном файле довольно сложно. Так мы начинаем разбивать его на несколько файлов и вставлять их в HTML через script-тег.
/*файловая структура - index_1.js - index_2.js - index_3.js - index.html */ //index.html <!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>Document</title> </head> <body> <div> User: <span id="user"></span> </div> <script src="./index_1.js"></script> <script src="./index_2.js"></script> <script src="./index_3.js"></script> </body> </html> //index_1.js var name = 'Boris'; function getNameLength() { return name.length; } //index_2.js var secondName = 'Boris'; function getSecondNameLength() { return secondName.length; } //index_3.js console.log(name, getNameLength()); //Boris 5
Так мы сталкиваемся с первой проблемой - отсутствие изоляции. Переменные и методы доступны всем файлам. Проблему решают IIFE-модули.
IIFE-модули
Нам нужно изолировать переменные в файлах. Но как? В JS у функций есть своя область видимости, определив функцию внутри файла и указав переменные внутри функции, мы изолируем их в границах файла.
// index_1.js function initModuleOne() { var _name = 'Boris'; function getNameLength() { return _name.length; } function getName() { return _name; } function setName(name) { _name = name; } return {getNameLength, getName, setName}; } var moduleOne = initModuleOne(); //index_3.js console.log(_name); //Uncaught ReferenceError: _name is not defined console.log(moduleOne.getName()); //Boris
Но можно сделать еще удобнее с помощью IIFE. Immediately invoked function expression - самовызывающиеся функции. В JS можно объявить функцию и сразу же её вызвать. Это позволяет делать так:
//index_1.js var moduleOne = (function() { var _name = 'Boris'; function getNameLength() { return _name.length; } function getName() { return _name; } function setName(name) { _name = name; } return {getNameLength, getName, setName}; })(); //index_2.js var moduleTwo = (function() { var _secondName = 'Johnson'; function getSecondNameLength() { return _secondName.length; } function getSecondName() { return _secondName; } function setSecondName(secondName) { _secondName = secondName; } return {getSecondNameLength, getSecondName, setSecondName}; })(); //index_3.js console.log(moduleOne.getName(), moduleTwo.getSecondName()); //Boris Johnson
Можно взять в пример популярную когда-то либу jQuery. При подключении в проект она экспортировала символ "$", который можно было использовать во всех скриптах, инциализированных в HTML после jQuery.
<!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>Document</title> </head> <body> <div> User: <span id="user"></span> </div> <script src="./jquery.js"></script> //скрипты раньше загружались браузером синхронно, поэтому если мы хотим, чтобы jquery был доступен скриптам ниже, нужно подключать его выше остальных скриптов <script src="./index_1.js"></script> <script src="./index_2.js"></script> <script src="./index_3.js"></script> </body> </html>
jQuery тоже использовал изоляцию переменных на уровне скоупа функции, объявляя внутри файла лишь переменную $:
function jQuery(a,c) { ... } var $ = jQuery;
Стоит заметить, что первая версия jQuery (1800 строк кода) сильно отличается от последней (10800 строк кода). В последних версиях jQuery учитывает наличие CommonJS, RequireJS-модулей, об этом ниже.
CommonJS модули
Вы наверняка знаете такой синтаксис подключения и экспорта скриптов:
var index1 = require('./index1.js'); var index2 = require('./index2.js'); var name = 'Boris'; module.exports = {name};
В 2009 году появился NodeJS. Если с браузерами все понятно - там можно добавлять скрипты через script-тег, то вот на сервере поддержка модулей нужна была, иначе как вообще писать приложения. Но так как поддержки модулей в самом языке не было даже на уровне спецификаций, ребятам пришлось придумывать свои решения. Так в NodeJS добавили поддержку модулей CommonJS, которые соответствовали спецификации CommonJS. CommonJS-модули синхронные, поэтому использовать их в браузере нельзя, ведь это было бы странно, если бы мы импортировали jQuery модуль и ждали бы пока он загрузится, блокируя инициализацию всего остального кода. Каждый файл согласно спецификации это отдельный модуль.
Под капотом весь контент файла NodeJS оборачивает в функцию:
(function (exports, require, module, __filename, __dirname) { function add (a, b) { return a + b } module.exports = add })
NodeJS кеширует все модули, поэтому импортировав модуль в нескольких местах, приложение будет использовать лишь один инстанс этого модуля. Исходники модуля NodeJS, который отвечает за реализацию CommonJS-модулей. Функция Module.prototype._compile
в исходниках отвечает за оборачивание файла (пример выше), а Module._load
отвечает за кеширование модулей.
CommonJS - синхронные (ждут загрузки других) модули для бэкендов на NodeJS
С CJS чуть разобрались, идем дальше.
AMD-спецификация и RequireJS-модули
AMD - это спецификация. То есть то, какое API должно быть у реализации модулей AMD. А вот RequireJS - это непосредственно реализация AMD-спецификации модулей. И она, в первую очередь, создана для браузерных скриптов. На странице проекта подробно объяснены варианты реализации загрузки модулей в браузере. Показаны все проблемы и то, как их решили с помощью AMD-модулей.
Давайте скачаем RequireJS-скрипт с офф. сайта и подключим к нашему проекту:
//index.html <!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>Document</title> </head> <body> <div> User: <span id="user"></span> </div> <script src="./jquery.js"></script> <script src="./require.js"></script> <script src="./index_3.js"></script> </body> </html> //index_1.js define( './index_1.js', function() { var _name = 'Boris'; function getNameLength() { return _name.length; } function getName(name) { return _name; } function setName(name) { $('#user').text(name); return _name = name; } return {getNameLength, getName, setName}; }); //index_2.js define( './index_2.js', function() { var _secondName = 'Johnson'; function getSecondNameLength() { return _secondName.length; } function getSecondName() { return _secondName; } function setSecondName(secondName) { return _secondName = secondName; } return {getSecondNameLength, getSecondName, setSecondName}; }); //index_3.js require(['./index_1.js', './index_2.js'], function(moduleOne, moduleTwo) { console.log(moduleOne.getName(), moduleTwo.getSecondName()); })
Хочу заметить, что теперь мы не подключаем скрипты index_1.js, index_2.js к проекту, requireJS автоматически добавляет их к HTML:
Название модуля в define является путем к файлу. Здесь можно было не указывать расширение .js
но для наглядности я его оставил.
Как же теперь подключить jQuery?
jQuery пока подключен глобально и переменная "$" доступна внутри модулей. Однако AMD модули не рекомендуют использование глобальных переменных. jQuery распознает наличие RequireJS и автоматически объявляет модуль "jquery", который можно подключить к любому модулю вот так:
define( 'index_1', ['jquery'], function(jquery) { var _name = 'Boris'; function getNameLength() { return _name.length; } function getName(name) { return _name; } function setName(name) { console.log(jquery); jquery('#user').text(name); return _name = name; } return {getNameLength, getName, setName}; });
Но все-таки рекомендуется явно объявлять библиотеки через глобальный конфиг:
//путь к jquery: js/lib/jquery-1.9.0.js requirejs.config({ baseUrl: 'js/lib', paths: { jquery: 'jquery-1.9.0' } });
Если мы попытаемся убрать глобальную переменную "$", столкнемся с кейсом, когда другие зависящие от jQuery плагины, которые не являются AMD-модулями, перестанут работать. В документации есть способ сделать глобальные переменные локальными, но мы опустим этот вопрос.
RequireJS также можно использовать и с синтаксисом CommonJS-модулей, так как колбэк в define принимает require функцию для динамического импорта зависимостей.
define( 'index_1', ['require', 'jquery'], function(require, jquery) { const moduleTwo = require('index_2'); console.log(moduleTwo); // Object { getSecondNameLength: getSecondNameLength(), getSecondName: getSecondName(), setSecondName: setSecondName(secondName) } } )
Для консистентности бэка и фронта RequireJS можно использовать и с NodeJS. А можно даже подключить React.
С RequireJS разобрались. Это либа для браузера, которая автоматически импортирует скрипты в HTML, убирает необходимость в использовании глобальных переменных и сама передает файлам нужные зависимости. Уже неплохо!
UMD-модули
Здесь стоит сразу сказать - UMD-модули это лишь паттерны. Это не библиотека, которую можно подключить к проекту. Их можно изучить на странице проекта.
UMD-модули необходимы, чтобы обрабатывать кейсы, когда на проекте используются модули разного типа. Например, представьте, что у вас есть модуль для работы со строками, который переносится из проекта к проекту. В одном проекте AMD-модулей может не быть, а в другом проекте вы захотите использовать его на бэке с NodeJS, где CJS (CommonSJ) модули. И чтобы вам каждый раз не приходилось адаптировать модуль под разные кейсы, были придуманы UMD-паттерны.
Например, давайте сделаем так, чтобы если на проекте нет RequreJS библиотеки, модули становились частью глобального объекта window:
//index_1.js (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['index_1'], ['index_2', 'jquery'], factory); } else { // Browser globals root.moduleOne = factory(root.moduleTwo, root.$); } }(typeof self !== 'undefined' ? self : this, function(moduleTwo, jquery) { var _name = 'Boris'; function getNameLength() { return _name.length; } function getName(name) { return _name; } function setName(name) { console.log(jquery); jquery('#user').text(name); return _name = name; } return {getNameLength, getName, setName}; })); //index_2.js (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['index_2'], factory); } else { // Browser globals root.moduleTwo = factory(); } }(typeof self !== 'undefined' ? self : this, function() { var _secondName = 'Johnson'; function getSecondNameLength() { return _secondName.length; } function getSecondName() { return _secondName; } function setSecondName(secondName) { return _secondName = secondName; } return {getSecondNameLength, getSecondName, setSecondName}; }));
ES-модули
В 2015 году с выходом ES6 впервые появились модули. Их синтаксис всем вам знаком:
<!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>Document</title> </head> <body> <div> User: <span id="user"></span> </div> <script type="module" src="./index_1.js"></script> <script type="module" src="./index_2.js"></script> <script type="module" src="./index_3.js"></script> </body> </html> //index_1.js const name = 'Boris'; export const getNameLength = () => { return name.length; } export const getName = (name) => { return name; } export const setName = (name) => { $('#user').text(name); } //index_2.js const name = 'Boris'; export const getSecondNameLength = () => { return name.length; } export const getSecondName = (name) => { return name; } //index_3.js import { getName } from "./index_1"; console.log(getName()); //Boris
Для того, чтобы браузер понял, что работает с модулем, необходимо указать в script теге type='module', в случае с NodeJS необходимо создавать файлы формата .mjs.
Можно заметить, что каждый модуль импортируется вручную в HTML, что не очень круто. В реальном мире в сыром виде модули не используются. Обычно используют сборщики, но о них мы поговорим в следующем материале.
Каждый модуль имеет свою область видимости. Импортируются зависимости через import
, экспортируются через export
. Для сравнения, в Python по дефолту все переменные экспортируются автоматически (конечно, можно сделать неэкспортируемые), и такое решение как-будто кажется более удобным. Вспомните кейсы, когда вы не экспортировали переменные в JS? Вот и я о том же. Как правило, функциональность внутри модуля в очень редких случаях используется только внутри него.
Deep Dive статья о работе ES-модулей.
Выводы
Итак, мы немного разобрались с тем как работают модули разного типа в JS. Сгодня в чистом виде их используют редко. Нам еще многое предстоить изучить. Например, что такое Bower и Browserify, как работают сборщики, траспиляторы (Babel), чем отличается современный и быстрый Vite сборщик от Webpack и Gulp и почему сборщик Rollup идеален для разработки библиотек. Об этом мы поговорим в следующих материалах.