CommonJS, RequireJS, UMD: модули JavaScript простыми словами

Олег Кусов18.06.2022
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 идеален для разработки библиотек. Об этом мы поговорим в следующих материалах.