Скорее всего, когда Брендан Айк проектировал JavaScript, он не представлял, как эволюционирует его проект спустя двадцать лет. На данный момент вышло уже шесть основных спецификаций языка, и работа над его улучшением до сих пор продолжается.

Не будем лукавить: JavaScript никогда не был идеальным языком программирования. Одним из слабых мест в JS была модульность, а точнее её отсутствие. Действительно, зачем в скриптовом языке, который анимирует падающие на странице снежинки и валидирует форму, заботиться об изоляции кода и зависимостях? Ведь всё может прекрасно жить и общаться между собой в одной глобальной области — window.

С течением времени JavaScript трансформировался в язык общего назначения, так его начали использовать для построения сложных приложений в различных средах (браузер, сервер). При этом нельзя было положиться на старые подходы взаимодействия компонентов программы через глобальную область: с ростом объёма кода приложение становилось очень хрупким. Как результат для упрощения процесса разработки создавались различные реализации модульности.

Эта статья появилась в результате общения с участниками TC39 и разработчиками фреймворков, а также чтения исходных кодов, блогов и книг. Мы рассмотрим следующие подходы/форматы: Namespace, Module, Detached Dependency Definitions, Sandbox, Dependency Injection, CommonJS, AMD, UMD, Labeled Modules, YModules и ES2015 Modules. Кроме того, мы восстановим исторический контекст их появления и развития.

Содержание



Используемые термины


Модульность решает следующие задачи: обеспечение поддержки изоляции кода, определение зависимостей между модулями и доставка кода в среду выполнения. Некоторые перечисленные выше подходы решали только одну-две из этих проблем — такие решения мы будем называть «паттернами», а те, где решаются все три задачи, — «модульными системами».

Конкретную структуру исходного кода с определением импортируемых и экспортируемых сущностей (ко второму типу относятся объекты, функции и т. п.) будем называть «форматом модулей».

Под «обособленным определением зависимостей» (detached dependency definition, DDD) будем понимать такие подходы определения зависимостей, которые можно использовать независимо от модульных систем.


Подробнее о проблемах


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

Коллизия имён


С момента своего появления JavaScript использовал глобальный объект window как хранилище всех определяемых переменных без ключевого слова var. В 1995-1999 годах это было очень удобно по причине малого количества клиентского JavaScript-кода на страницах. Но с увеличением количества кода эта особенность стала приводить к частому возникновению ошибок из-за коллизии имен. Давайте рассмотрим пример:

// файл greeting.js
var helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

function writeHello(lang) {
    document.write(helloInLang[lang]);
}

// файл hello.js
function writeHello() {
    document.write('The script is broken');
}


Когда на страницу подключится greeting.js, а затем hello.js, мы из-за коллизии вместо приветствия получим сообщение «The script is broken».

Очевидно, что в больших проектах это может вызывать много головной боли. Более того — нельзя быть уверенным, что подключаемые к странице сторонние скрипты ничего не поломают в вашем приложении.

Поддержка большой кодовой базы


Ещё один неудобный момент при использовании JavaScript «из коробки» для построения больших приложений — это необходимость явно указывать подключаемые скрипты с помощью тега script.

Если вы заботитесь о том, чтобы исходный код был удобен для поддержки, вы разбиваете его на независимые части. Таким образом, файлов с кодом может оказаться очень много. При большом количестве файлов ручное управление скриптами (то есть определение подключаемых скриптов через тег script) сильно усложняется: необходимо, во-первых, помнить о подключении нужных скриптов к странице, а во-вторых — распределить последовательность тегов script так, чтобы все зависимости между файлами были разрешены.


Directly Defined Dependencies (1999)


Первой попыткой привнести модульную структуру в JavaScript, а также первой реализацией обособленного определения зависимостей было прямое определение зависимостей в коде (directly defined dependencies). Одним из первых этот паттерн задействовал Эрик Арвидссон (ныне участник TC39) в далёком 1999 году.

Эрик тогда работал в стартапе, где создавалась платформа для запуска GUI-приложений в браузере — WebOS (обратите внимание: речь не про webOS от Palm). WebOS была проприетарной платформой — мне не удалось получить её исходный код. Поэтому рассмотрим реализацию паттерна на примере библиотеки Dojo, которую с 2004 года разрабатывали Алекс Рассел и Дилан Скиманн.

Суть прямого определения зависимостей — загружать код модулей (в терминах Dojo — ресурсы) при явном исполнении функции dojo.require (которая, кроме того, инициализировала загруженный модуль). При таком подходе зависимости определяются по месту требования, прямо в коде.

Давайте переделаем наш пример с использованием Dojo 1.6:

// файл greeting.js
dojo.provide("app.greeting");

app.greeting.helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

app.greeting.sayHello = function (lang) {
    return app.greeting.helloInLang[lang];
};

// файл hello.js
dojo.provide("app.hello");

dojo.require('app.greeting');

app.hello = function(x) {
    document.write(app.greeting.sayHello('es'));
};


Здесь мы видим, что модули определяются с помощью функции dojo.provide, а сама загрузка кода происходит при выполнении функции dojo.require. Это довольно простой подход, который использовался в Dojo до версии 1.7 и по сей день используется в Google Closure Library.


Namespace Pattern (2002)


Первая попытка решения проблемы коллизии имен заключалась во введении соглашений. Например, можно было добавить к именам всех переменных и функций определённый префикс: myApp_ (myApp_address, myApp_validateUser()) или какой-нибудь другой. Конечно, кардинально это не меняло ситуацию, так что впоследствии разработчики стали пользоваться ключевой особенностью JavaScript: функции в нём являются объектами первого класса.

Объект первого класса — сущность, которую можно присваивать переменным и свойствам объектов и возвращать из других функций. То есть можно создавать объекты с такими же свойствами-функциями (методами), как у объектов document и window (document.write(), window.alert()).

Один из первых значимых проектов, где была использована данная возможность, — библиотека UI-элементов Bindows. Над ней в 2002 году начал работать уже знакомый нам Эрик Арвидссон. Вместо префиксов в названиях функций и переменных он воспользовался глобальным объектом, свойства которого содержали данные и логику библиотеки. Таким образом, в значительной степени уменьшилось загрязнение глобальной области. Сейчас подобный паттерн для организации кода известен под названием «Пространство имен» (Namespace Pattern).

Переложим эту идею на наш пример.

// файл app.js
var app = {};

// файл greeting.js
app.helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

// файл hello.js
app.writeHello = function (lang) {
    document.write(app.helloInLang[lang]);


Как мы видим, логика и данные теперь содержатся в свойствах объекта app. Тем самым снижается загрязнение глобальной области, но мы продолжаем иметь доступ к необходимым частям приложения из разных файлов.

На сегодняшний день Namespace Pattern — наверное, самый популярный паттерн, который можно встретить в JS. После Bindows подобная логика появилась в Dojo (2005), YUI (2005) и многих других библиотеках и фреймворках. Стоит отметить, что Эрик не считает себя автором этого подхода, но вспомнить, какой проект вдохновил его на использование паттерна, он, к сожалению, не смог.


Module Pattern (2003)


Благодаря Namespace организация кода стала чуть менее беспорядочной, но было очевидно, что этого недостаточно: до сих пор не была решена задача изоляции кода и данных.

Первопроходец в решении указанной задачи — паттерн «Модуль». Идея паттерна — инкапсулировать данные и код с помощью замыкания и сделать их доступными через методы, которые, в свою очередь, доступны извне. Вот базовый пример такого модуля:

var greeting = (function () {
    var module = {};
    var helloInLang = {
        en: 'Hello world!',
        es: '?Hola mundo!',
        ru: 'Привет, мир!'
    };

    module.getHello = function (lang) {
        return helloInLang[lang];
    };

    module.writeHello = function (lang) {
        document.write(module.getHello(lang))
    };

    return module;
}());


Здесь мы видим самовызывающуюся функцию — такую, которая выполняется сразу после объявления. Она возвращает объект module, где есть метод getHello(), обращающийся к объекту helloInLang через замыкание. Таким образом, helloInLang становится недоступен из внешнего мира и мы получаем атомарный кусок кода, который можно вставить в любой другой скрипт без конфликта имён — даже если объект helloInLang был объявлен где-то ещё.

В сети первое упоминание этого подхода появилось в 2003 году, когда Ричард Корнфорд привёл пример модуля в группе comp.lang.javascript в качестве иллюстрации использования замыканий. В 2005-2006 году этот подход взяли на вооружение разработчики фреймворка YUI из Yahoo! под руководством Дугласа Крокфорда. Но наибольший импульс к его распространению был дан в 2008 году, когда Дуглас описал паттерн «Модуль» в своей книге JavaScript Good Parts.

Но и это еще не всё. В статье «JavaScript Module Pattern: In-Depth» (вот перевод на Хабрахабре) есть множество разных вариантов реализации модуля. Рекомендую посмотреть.


Template Defined Dependencies (2006)


Шаблонное определение зависимостей — следующий паттерн в семействе обособленного определения. Самый ранний из найденных мной проектов, где задействован этот подход, — Prototype 1.4 (2006), но у меня есть подозрение, что он использовался и в более ранних версиях библиотеки.

Prototype разрабатывался с 2005 года Сэмом Стивенсеном как клиентская часть фреймворка Ruby on Rails. Поскольку Сэм много работал с Ruby, неудивительно, что для менеджмента зависимостей между файлами он выбрал обычную шаблонизацию с помощью erb.

Если попробовать обобщить, можно сказать, что в этом паттерне зависимости определяются с помощью включения в целевой файл специальных меток. Здесь могут использоваться как распространенные механизмы шаблонизации (erb, jinja, smarty), так и специальные инструменты сборки, например borsсhik.

При использовании шаблонизированных зависимостей — в отличие от ранее рассмотренных паттернов обособленного определения — обязателен предварительный этап сборки.

Преобразуем наш пример с использованием описанного стиля. Для этого задействуем borsсhik:

// файл app.tmp.js
/*borschik:include:../lib/main.js*/

/*borschik:include:../lib/helloInLang.js*/

/*borschik:include:../lib/writeHello.js*/

// файл main.js
var app = {};

// файл helloInLang.js
app.helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

// файл writeHello.js
app.writeHello = function (lang) {
    document.write(app.helloInLang[lang]);
};


Здесь файл app.tmp.js определяет подключаемые скрипты и их порядок. Если поразмышлять над примером, станет ясно, что данный подход кардинально не меняет жизнь разработчику. Вместо использования тегов script, просто используются другие метки в js-файле. Иными словами, мы по-прежнему можем что-то забыть или перепутать порядок подключаемых скриптов. Поэтому основное назначение этого подхода — обеспечение сборки единого js-файла из множества других.


Comment Defined Dependencies (2006)


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

Приложение, использующее данный подход, должно быть предварительно собрано — как это делалось в 2006 году для сборки MooTools, которую разрабатывал Валерио Пройетти, — или оно должно на этапе исполнения парсить исходный код и затем загружать все определенные с помощью комментариев зависимости. На базе последнего подхода реализована библиотека LazyJS Николаса Бевакуа.

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

// файл helloInLang.js
var helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

// файл sayHello.js
/*! lazy require scripts/app/helloInLang.js */

function sayHello(lang) {
    return helloInLang[lang];
}

// файл hello.js
/*! lazy require scripts/app/sayHello.js */

document.write(sayHello('en'));


Самая известная библиотека, где используется указанный подход, — MooTools. LazyJS была интересным экспериментом, но она появилась после CommonJS и AMD и поэтому особого внимания разработчиков не получила.


Externally Defined Dependencies (2007)


Давайте рассмотрим последний паттерн в семействе DDD. При внешнем определении зависимостей они все определяются вне основного контекста — например, в конфигурационном файле или в коде как объект или массив. При этом существует этап предподготовки, во время которого происходит инициализация приложения с загрузкой всех зависимостей в корректном порядке — на основе имеющейся о них информации.

Самое раннее использование такого подхода, которое я смог найти, датируется 2007 годом. Речь идёт о библиотеке MooTools 1.1.

В самом простом случае реализовать наш пример с использованием этого паттерна можно следующим образом (в качестве образца буду использовать собственную экспериментальную реализацию загрузчика, где задействован нужный паттерн):

// файл deps.json
{
    "files": {
        "main.js": ["sayHello.js"],
        "sayHello.js": ["helloInLang.js"],
        "helloInLang.js": []
    }
}

// файл helloInLang.js
var helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};


// файл sayHello.js
function sayHello(lang) {
    return helloInLang[lang];
}


// файл main.js
console.log(sayHello('en'));


Файл deps.json является тем самым внешним контекстом, где определяются все зависимости. При запуске приложения загрузчик получает файл, считывает из него зависимости, которые определяются в виде массива, загружает их и подключает к странице в корректном порядке.

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


Sandbox Pattern (2009)


Программисты Yahoo!, работавшие над новой модульной системой YUI3, решали проблему использования разных версий библиотеки на одной странице. До YUI3 модульная система во фреймворке реализовывалась комбинацией паттернов Module и Namespace. Очевидно, что при такой схеме корневой объект, содержащий код библиотеки, мог быть только один и, следовательно, использовать несколько версий сразу было затруднительно.

Чтобы решить возникшую проблему, один из разработчиков YUI3 Адам Мур предложил задействовать «Песочницу». Простая реализация модульности с использованием этого паттерна может выглядеть так:

// файл sandbox.js
function Sandbox(callback) {
    var modules = [];
    for (var i in Sandbox.modules) {
        modules.push(i);
    }
    for (var i = 0; i < modules.length; i++) {
        this[modules[i]] = Sandbox.modules[modules[i]]();
    }
    callback(this);
}

// файл greeting.js
Sandbox.modules = Sandbox.modules || {};

Sandbox.modules.greeting = function () {
    var helloInLang = {
        en: 'Hello world!',
        es: '?Hola mundo!',
        ru: 'Привет, мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
};

// файл app.js
new Sandbox(function(box) {
    document.write(box.greeting.sayHello('es'));
});


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

«Песочница» послужила интересным решением проблемы модульности, но за пределами YUI3 особого распространения не получила. Если вам хочется узнать больше про Sandbox, рекомендую статью «Javascript Sandbox Pattern», а также официальную документацию YUI про создание новых модулей библиотеки.


Dependency Injection (2009)


В 2004 году Мартин Фаулер для описания нового механизма коммуникации компонентов в Java ввёл понятие «внедрение зависимостей» (dependency injection, DI). Основная суть заключается в том, что все зависимости «приходят» извне компонента. Другими словами, компонент не отвечает за инициализацию своих зависимостей, а лишь использует их.

Пять лет спустя Мишко Хевери, бывший сотрудник Sun и Adobe (где он занимался в том числе разработкой на Java), начал проектировать для своего стартапа JavaScript-фреймворк, где ключевым механизмом взаимосвязей компонентов служило внедрение зависимостей. Идея бизнеса не доказала свою эффективность, но исходный код фреймворка решили выложить на домене стартапа getangular.com. Большинство знает, что было потом: компания Google взяла Мишко и его проект под крыло и сейчас Angular — один из самых известных JavaScript-фреймворков.

Модули в Angular реализуются с помощью механизма DI. Однако модульность — не первичное назначение DI: об этом также явно говорит Мишко в ответе на соответствующий вопрос.

Для иллюстрации подхода давайте перепишем наш пример с использованием первой версии Angular (да, пример получился чрезвычайно синтетическим):

// файл greeting.js
angular.module('greeter', [])
    .value('greeting', {
        helloInLang: {
            en: 'Hello world!',
            es: '?Hola mundo!',
            ru: 'Привет, мир!'
        },
        sayHello: function(lang) {
            return this.helloInLang[lang];
        }
    });

// файл app.js
angular.module('app', ['greeter'])
    .controller('GreetingController', ['$scope', 'greeting', function($scope, greeting) {
        $scope.phrase = greeting.sayHello('en');
    }]);


Если открыть страницу с примером в браузере, то код магическим образом отработает и мы увидим результат на странице.

Сейчас механизм DI используется во фреймворках Angular 2 и Slot. Также существует большое количество библиотек, упрощающих использование этого подхода в приложениях, не зависящих от каких-либо фреймворков.


CommonJS Modules (2009)


Вместе с браузерными JavaScript-движками ещё до появления Node.js разрабатывались платформы для серверной разработки, использующие JavaScript как основной язык. Серверные решения ввиду отсутствия соответствующих спецификаций не предоставляли унифицированного API для работы с операционной системой и внешним окружением (файловой системой, сетью, переменными окружения и т. д.), тем самым создавая проблемы с распространением кода. Например, скрипты, написанные для старичка Netscape Enterprise Server, не работали в Rhino и наоборот.

В 2009 году наступил переломный момент — сотрудник Mozilla Кевин Дангур опубликовал пост о проблемах с серверным JavaScript. В посте он призвал всех заинтересованных присоединиться к неофициальному комитету для обсуждения и разработки серверного JavaScript API. Проект, над которым началалась работа, был назван ServerJS; но спустя его переименовали в CommonJS.

Работа закипела. Наибольшее внимание разработчиков получила спецификация формата модулей в JavaScript — CommonJS Modules (иногда её называют CJS или просто CommonJS), которая в конечном счёте была реализована в Node.js.

Для демонстрации CommonJS-модуля давайте адаптируем наш модуль следующим образом:

// файл greeting.js
var helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

var sayHello = function (lang) {
    return helloInLang[lang];
}

module.exports.sayHello = sayHello;


// файл hello.js
var sayHello = require('./lib/greeting').sayHello;

var phrase = sayHello('en');
console.log(phrase);


Здесь мы видим, что для реализации модульности вводятся новые сущности require и exports (альяс на module.exports), которые дают возможность загрузить модуль и предоставить его интерфейс внешнему миру. Стоит заметить, что ни require, ни exports, ни module не являются ключевыми словами языка — в Node.js они появляются благодаря обёртке, куда заворачиваются все модули, прежде чем отправиться на выполнение в JavaScript-движок:

(function (exports, require, module, __filename, __dirname) {
    // ...
    // Your code is injected here!
    // ...
});


Спецификация CommonJS определяет только необходимый минимум для интероперабельности модулей в различных средах. Значит, механизмы CommonJS можно расширять. Например, так делает Node.js, добавляя к require свойство main, которое указывает на module, если файл с модулем был запущен напрямую.

Babel также расширяет require при транспиляции модуля с дефолтным экспортом в формате ES2015 Modules (об этой модульной системе поговорим в конце статьи):

export default something;


Babel преобразует подобный экспорт в CommonJS-модуль, где дефолтное значение экспортируется с помощью соответствующего свойства. То есть, упрощённо, получается следующий код:

exports.default = something;


Система сборки webpack тоже использует различные расширения, например require.ensure, require.cache, require.context, но их обсуждение лежит вне контекста статьи.

На сегодняшний день CommonJS — самый распространённый формат модулей. Этот формат используется не только в Node.JS — ещё его можно использовать при разработке клиентских веб-приложений, собирая все модули в единый файл с помощью Browserify или webpack.


AMD (2009)


Когда разработка спецификации CommonJS шла полным ходом, в рассылке время от времени возникали бурные обсуждения о добавлении в спецификацию возможности асинхронной загрузки модулей. Она позволила бы ускорить загрузку веб-приложений, состоящих из большого количества файлов, и избавиться от необходимости сборки для доставки кода пользователям.

Коллега Кевина по работе в Mozilla Джеймс Бёрк был одним из самых активных сторонников асинхронной загрузки во всех обсуждениях. Джеймс мог выступать экспертом, поскольку был автором асинхронной модульной системы во фреймворке Dojo 1.7. Кроме того, именно он с 2009 года разрабатывал загрузчик require.js.

Основная идея, которую пытался донести Джеймс, — что загрузка модулей не должна осуществляться синхронно (то есть последовательно, один модуль за другим); нужно использовать возможности браузера для параллельной загрузки скриптов. Для реализации всех требований впоследствии он предложил собственный формат модулей, который был назван AMD (Asynchronous Module Definition).

Если переписать наш пример в соответствии с практиками AMD, то мы получим следующий код:

// file lib/greeting.js
define(function() {
    var helloInLang = {
        en: 'Hello world!',
        es: '?Hola mundo!',
        ru: 'Привет, мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
});

// file hello.js
define(['./lib/greeting'], function(greeting) {
    var phrase = greeting.sayHello('en');
    document.write(phrase);
});


Здесь файл hello.js является точкой входа в программу. В нём находится функция define, которая объявляет модуль. В качестве первого аргумента в функцию передаётся массив зависимостей. При этом выполнение основного кода модуля, который объявлен как функция вторым аргументом в define, будет запущено, только когда загрузятся все зависимости модуля. Именно отложенное выполнение кода каждого модуля в отдельности обеспечивает параллельную загрузку зависимостей в целом.

В 2011 году наступил переломный момент всех дискуссий: Джеймс объявил о создании отдельной рассылки для координации работ по AMD, поскольку консенсус с группой CommonJS за всё это время достигнут не был.

По личным наблюдениям могу сказать, что AMD всё ещё используется при разработке приложений, однако тенденция перехода к распространению клиентского кода через npm и отказ от bower уводят разработчиков от AMD всё дальше и дальше.


UMD (2011)


На самом деле явное противостояние форматов модулей началось ещё до того, как AMD отделился от CommonJS Modules. Уже тогда в лагере AMD было много разработчиков, которым нравился минимальный порог вхождения для начала работы с модульным кодом. Количество приверженцев CommonJS Modules из-за роста популярности Node.js и появления Browserify тоже росло очень быстро.

Фактически у нас было два стандарта, которые не могли ужиться друг с другом. AMD-модули без модификации кода нельзя было задействовать в средах, реализующих спецификацию CommonJS Modules (Node.js), а модули CommonJS не удавалось использовать с инструментами, поддерживающими AMD: RequireJS, curl.js. Да, впоследствии появилась возможность использовать RequireJS для работы с CommonJS-модулями, но такое положение дел всё равно никого не устраивало. Именно для решения проблемы переносимости кода между разными системами модульности и был разработан паттерн UMD — Universal Module Definition.

Найти настоящего автора паттерна оказалось довольно сложно — пришлось провести небольшое расследование. Сначала я обратился к автору репозитория паттернов UMD на GitHub Эдди Османи. Он вывел меня на Джеймса Бёрка и Криса Коваля, а они, в свою очередь, сослались на репозиторий реализации промисов Q.

Со дня своего появления библиотека Q могла работать в разных окружениях: в браузере (при подключении модуля через тег script) и на сервере в Node.js и Narwhal (через CommonJS Modules). Кроме того, Джеймс Бёрк через некоторое время добавил в проект Q поддержку AMD. Затем Эдди Османи систематизировал все похожие шаблоны в одном общем репозитории, который получил название UMD. Результат подобной адаптации кода для разных систем модульности мы сейчас и называем UMD.

Давайте в качестве примера переделаем наш игрушечный модуль для одновременной работы в окружениях CommonJS и AMD:

(function(define) {

    define(function () {
        var helloInLang = {
            en: 'Hello world!',
            es: '?Hola mundo!',
            ru: 'Привет, мир!'
        };

        return {
            sayHello: function (lang) {
                return helloInLang[lang];
            }
        };
    });

}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));


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

function (factory) {
    module.exports = factory();
}


Это если код используется как CommonJS-модуль. Если же код используется как AMD-модуль, в качестве аргумента передаётся функция define. Адаптация кода под различные окружения происходит как раз благодаря такой подмене.

Сейчас большинство разработчиков, когда им надо обеспечить возможность использования библиотеки и в браузере, и в Node.js, пользуются именно форматом UMD. Экспорт в UMD применяется в разных популярных библиотеках, например в moment.js и lodash.


Labeled Modules (2012)


В 2010 году в комитете ТС39 началась работа над полноценной модульной системой в JavaScript. На тот момент система называлась ES6 Modules. К 2012 году уже стало примерно ясно, каким будет ее окончательный вид. Один из участников комитета, Себастьян Маркбейдж (на данный момент также ведущий разработчик React), по собственной инициативе подготовил транзитивный формат модулей. Предполагалось, что можно было бы задействовать его в средах, работающих даже с ES3, а впоследствии легко адаптировать приложение, использующее этот формат, под новый стандарт модулей. Формат получил название Labeled Modules.

Основная идея — применение меток (labels). Поскольку ключевые слова import и export зарезервированы в языке, метки их использовать не могли и были придуманы соответствующие синонимы: для определения экспорта — exports, для импорта — require.

Как обычно, давайте переделаем наш пример, чтобы показать формат в действии.

// файл greeting.js
var helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

exports: var greeting = {
    sayHello: function (lang) {
        return helloInLang[lang];
    }
};

// файл hello.js
require: './lib/greeting';

var phrase = greeting.sayHello('es');
document.write(phrase);


Пример конфига для сборки приложения, использующего Labeled Modules, можно посмотреть тут.

Как мы видим, получилось довольно элегантно. Но поскольку в 2012 году уже царствовали форматы CommonJS и AMD, конкурировать с ними было тяжело. В итоге, даже когда поддержка Labeled Modules появилась в первом webpack, особого распространения среди JS-разработчиков она всё равно не получила.


YModules (2013)


Как вы наверное догадались по названию Яндекс тоже не сидел сложа руки и сделал свою собственную модульную систему ;). Зачем нам надо было создавать что-то свое, когда можно было воспользоваться существующими форматами CommonJS или AMD? Дело в том, что хотя они и предоставляют возможность определения зависимостей и должный уровень изоляции кода, но для решения наших возникших задач ни CommonJS, ни AMD не подходили.

Было два основных дополнительных требования. Для реализации асинхронных API (например API Яндекс.Карт) нужно было, чтобы JS-модули с асинхронной природой использовались как можно более прозрачно. Кроме того, требовалась возможность использовать модули с уровнями переопределения БЭМ, иначе говоря — доопределять модули.

В 2013 году команды Карт и БЭМа окончательно выработали спецификацию новой системы, которую впоследствии воплотил в жизнь Дмитрий Филатов dfilatov.

Вот реализация нашего примера с помощью YModules:

// файл greeting.js
modules.define('greeting', function(provide) {
    provide({
        helloInLang: {
            en: 'Hello world!',
            es: '?Hola mundo!',
            ru: 'Привет, мир!'
        },
        sayHello: function (lang) {
            return this.helloInLang[lang];
        }
    });
});

// файл app.js
modules.require(['greeting'], function(greeting) {
    document.write(greeting.sayHello('ru'));
});
// Результат: "Привет, мир!"


YModules по своей структуре напоминает AMD. Основное отличие — предоставление интерфейса модуля внешнему миру происходит с помощью специальной функции provide, а не return.

Указанная особенность позволяет осуществлять «провайд» из блоков асинхронного кода, тем самым скрывая асинхронную природу модуля от внешнего мира. Например, если мы добавим в greeting.js какую-либо асинхронную работу (в данном случае — setTimeout), то весь код, где используется этот модуль, останется без изменений:

// файл greeting.js
modules.define('greeting', function(provide) {
    // откладываем выполнение кода на 1 секунду
    setTimeout(function () {
        provide({
            helloInLang: {
                en: 'Hello world!',
                es: '?Hola mundo!',
                ru: 'Привет, мир!'
            },
            sayHello: function (lang) {
                return this.helloInLang[lang];
            }
        });
    }, 1000);
});


// файл: app.js
modules.require(['greeting'], function(greeting) {
    document.write(greeting.sayHello('ru'));
});

// Результат: "Привет, мир!"


Как было сказано выше, ещё одной отличительной чертой YModules является возможность работы с уровнями переопределения. Давайте рассмотрим её немного подробнее.

// файл moduleOnLevel1.js
modules.define('greeting', function(provide) {
    provide({
        helloInLang: {
            en: 'Hello world!',
            es: '?Hola mundo!',
            ru: 'Привет, мир!'
        },
        sayHello: function (lang) {
            return this.helloInLang[lang];
        }
    });
});


// файл moduleOnLevel2.js
modules.define('greeting', function(provide, module) {
    // переопределяем метод sayHello
    module.sayHello = function (lang) {
        return module.helloInLang[lang].toUpperCase();
    };

    provide(module);
});


// файл app.js
modules.require(['greeting'], function(greeting) {
    document.write(greeting.sayHello('ru'));
});

// Результат: "ПРИВЕТ, МИР!"


Если выполнить этот пример, метод sayHello в результате доопределения модуля greeting изменится на новый, и текст выводимого сообщения преобразуется к верхнему регистру. Всё благодаря тому, что в YModules при повторной декларации модуля его предыдущая версия будет содержаться в его же последнем аргументе (например, module как в примере выше).

На данный момент YModules используется в проектах Яндекса. Ещё это основная модульная система клиентского фреймворка i-bem.js.


ES2015 Modules (2015)


Комитет разработки ECMAScript (TC39), конечно, наблюдал за всем, что творилось в мире JavaScript. Стало очевидно, что пришло время для серьёзных изменений в языке.

В 2010 году над нативной модульной системой начал трудиться директор по стратегическому развитию Mozilla Дэйв Хёрман. Работа над спецификацией продолжалась в течение пяти лет, причём Дейв параллельно занимался другими задачами. За это время он успел побывать в роли архитектора и ведущего разработчика в проектах asm.js, emscripten, servo.

И вот, в 2015 году была выпущена спецификация ES2015, которая, в свою очередь, содержала в себе окончательную версию спецификации модулей. Давайте по традиции адаптируем наш пример:

// файл lib/greeting.js
const helloInLang = {
    en: 'Hello world!',
    es: '?Hola mundo!',
    ru: 'Привет, мир!'
};

export const greeting = {
    sayHello: function (lang) {
        return helloInLang[lang];
    }
};

// файл hello.js
import { greeting } from "./lib/greeting";

const phrase = greeting.sayHello("ru");
document.write(phrase);


Как мы видим, стандарт вводит абсолютно новые конструкции языка для импорта модулей через ключевое слово import и для экспорта кода через export.

Из-за того, что мы имеем дело с новыми ключевыми словами языка, а также в виду того, что спецификация Module Loader API, отвечающая за поддержку загрузки модулей в различных средах, еще не готова, мы не можем просто так взять и начать использовать новую систему модульности.

Впрочем, ES2015 всё равно применяется в большом количестве проектов. Чтобы начать использовать новый стандарт в мире, где пока ещё правит ES5, можно воспользоваться транспиляцией при помощи Babel: это довольно распространённая практика.


Итого


Существуют и другие подходы к организации модульности в JS. Некоторые из них могут переплетаться друг с другом, образуя причудливые формы, другие были созданы под конкретные проекты, а какие-то создавались в качестве транзитивного формата. Описать их все — очень непростая задача, поэтому в статье рассмотрены только более-менее популярные подходы и форматы. Тем не менее, думаю, статья помогла вам систематизировать знания о модульности, узнать о чём-то новом и о тех людях, которые стояли за упомянутыми технологиями.

Не могу не порекомендовать замечательную статью «Путь JavaScript модуля» Михаила Давыдова, где модульность рассматривается вкупе с загрузчиками и бандлерами, а также приводятся плюсы и минусы всех подходов к организации модульной структуры приложения.

Примеры из статьи доступны на GitHub.
Поделиться с друзьями
-->

Комментарии (32)


  1. k12th
    16.03.2017 11:57
    +4

    Мне нравились AMD-модули при всех их недостатках — кажется, это очень элегантно, когда модульную систему можно реализовать в рамках самого языка, не вводя новый синтаксис и препроцессинг, раз уж из коробки не завезли.


    1. vintage
      16.03.2017 19:26
      +1

      Основная беда нативных модулей — невозможность объединить несколько модулей в один файл. И ладно бы была поддержка jar-ов, так нет же, нативные модули невозможно использовать на клиенте без компиляции в какой-нибудь UMD.


      И нет, http/2 не решает проблему "пока модуль не загружен, браузер не знает какие зависимости надо загрузить ещё".


      1. NLO
        16.03.2017 19:45

        НЛО прилетело и опубликовало эту надпись здесь


        1. vintage
          16.03.2017 20:54

          Тут проблема в синтаксисе, загрузчик никаким образом её не решит.


          1. NLO
            16.03.2017 21:27

            НЛО прилетело и опубликовало эту надпись здесь


            1. vintage
              16.03.2017 22:19

              нативные модули невозможно использовать на клиенте без компиляции в какой-нибудь

              … строку в кастомном формате..


      1. Aries_ua
        17.03.2017 00:07

        Можно уточнить, а почему нельзя?

        Сейчас у меня написан таск для Gulp который собирает модуль в один файл. При этому модуль содержит кучу AMD файлов, которые находятся внутри модуля.

        Возможно я не правильно вас понял. Тогда можете поподробнее пояснить.

        Заранее спасибо!


        1. vintage
          17.03.2017 07:30

          Речь про нативные модули (последние в статье). С амд -то всё ок.


          1. Alternator
            17.03.2017 14:48

            Я все еще не понимаю почему нельзя.
            Есть webpack.
            В предыдущей версии дополненный babel-ем прекрасно собирает нативные модули в один файл.
            В webpack@2 вроде и без babel умеет.

            Да, внутренняя кухня сложнее(трансформация кода вместо простой склейки).
            Но результат впрочем тот же — собранный файл, с возможностью отделить необязательные модули в отдельный файл для ленивой загрузки.
            Ну и бонусом куча дополнительных плюшек сборки.


            1. vintage
              17.03.2017 15:57
              +1

              В предыдущей версии дополненный babel-ем прекрасно собирает нативные модули в один файл.

              Ну вы загляните внутрь этого файла. Нативные модули преобразуются в ненативные (в тот же амд). То есть для самого распространённого юзкейса нативные модули не годятся и приходится заниматься сложной транспиляцией. И это не временная мера — с этой зависимостью от пропиетарных транспайлеров мы останемся на долго.


              1. MikailBag
                21.03.2017 21:33

                Я немного не понял, что Вы имеете в виду под проприетарными транспайлерами?


                1. vintage
                  21.03.2017 23:21

                  Утилиты, которые берут стандартные модули и преобразуют их в нестандартные. В результате модули собранные одним бандлером не возможно подгрузить из модулей, собранных другим бандлером.


                  1. MikailBag
                    22.03.2017 20:57

                    Зачем нужно использовать результат работы бандлера в каких-либо модулях?
                    Почему нельзя напрямую использовать исходные коды, и бандлы использовать только как скрипты из нулевых?


                    1. vintage
                      23.03.2017 08:01

                      Чтобы негрузить все 10 мегабайт кода сразу, чтобы подключить яндекс-метрику, гугл-карты, вк-комментарии и прочие сторонние апи.


            1. Semigradsky
              17.03.2017 17:41
              +1

              Webpack не умеет собирать ES6 модули в один файл.


              То, что вы называете "прекрасно собирает нативные модули в один файл", это преобразование ES6 модулей в CommonJS модули и собирание в один файл уже их.


  1. rumkin
    16.03.2017 15:06

    К модульным системам пока что остается много вопросов. Но в первую очередь это отсутсвие механизма управления подключением скриптов в API браузера, до этого момента все попытки будут в некоторой форме костылями. Даже вводимый import не решит проблемы, в виду того, что воспроизвести его поведение будет невозможно, т.е. мы получим очередное неуправляемое поведение, что согласитесь странно.


    Теперь, что касается YModules. Концепция логичная, но я бы заменил provide промисом. Гораздо удобнее и лаконичнее, по-моему:


    modules.define('theTrue', () => {
        return {
             theTrue: true,
        };
    });
    
    modules.define('theTrue.async', () => {
        return Promise.resolve({
             theTrue: true,
        });
    });


    1. Myshov
      16.03.2017 17:04

      Согласен, это довольно распространенная практика — возвращать из асинхронных модулей промисы. Но если у вас есть обычный модуль (без асинхронной работы) и вы используете его в большом количестве мест, при этом у вас также появляются другие пользователя этого модуля. То, когда происходит изменение интерфейса (в данном примере обычный модуль начинает возвращать промис), вам надо будет исправить все места, где используется этот модуль и каким-то образом оповестить других пользователей этого модуля об изменившемся интерфейсе, что в комплексе получается не очень удобно.


      1. justboris
        16.03.2017 17:33

        В том-то и дело, что пользователям менять ничего не надо.


        Библиотека должна понимать, что если вернулся промис, нужно подождать пока он разрезолвится. Пользователи будут получать результат как и раньше, когда модуль возвращал синхронный результат.


        1. Myshov
          16.03.2017 17:41

          Почему не надо?

          В первом случае модуль возвращает объект:

          return {
               theTrue: true,
          };
          


          Во втором — зарезолвленный промис:
          return Promise.resolve({
               theTrue: true,
          });
          


          В последнем случае пользователю библиотеки надо будет добавить
          .then(yourFunction)


          1. justboris
            16.03.2017 17:48

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


            Сравните сами. Что смотрится опрятнее?
            Provide


            modules.define('asyncModule', provide => {
               fetch('/api/endpoint').then(response => provide(response))
            });

            Promise


            modules.define('asyncModule', () => {
               return fetch('/api/endpoint');
            });


            1. Myshov
              16.03.2017 18:00

              Имхо это тоже самое, только в профиль. Если возвращать промис, то в вашем случае пользователь библиотеки должен соответствующим образом его обработать.

              modules.require(['asyncModule'], function(asyncModule) {
                  asyncModule.then(response => doSomething(response));
              });
              


              При этом пользователь становится привязан к промису, т.е. если интерфейс библиотеки в будущем вновь станет возвращать объект, это вызовет ошибку.


              1. justboris
                16.03.2017 18:06
                +2

                Ну нет же.
                Можно сделать YModules чуть умнее и научить резолвить промис, полученный от функции модуля.
                И пользователь получит результат резолва и никак не заметит, что модуль стал асинхронным


                modules.require(['asyncModule'], function(asyncResponse) {
                  console.log(asyncResponse);
                });


                1. Myshov
                  16.03.2017 18:12

                  Ок, интересная мысль


      1. rumkin
        16.03.2017 18:46

        Вы не так поняли. Я предлагаю спрятать provide внутрь метода define, т.е. чтобы provide вызывался на успешное разрешение промиса. Так же промисы помогут избежать еще одной проблемы: в данной реализации некуда передавать ошибку возникающую при асинхронной инициализации модуля, она легко может потеряться. Приведу пример интерфейса загрузки модулей в singular:


        singular.inject(['mongo', 'redis'])
        .then(([mongo, redis]) => {
            // Отлично! Работаем дальше.
        }, (err) => {
            // Обрабатываем ошибки.
        });

        Как видите здесь используются стандартные возможности языка и появляется возможность применить await, например так:


        let [mongo, redis] = await singular.inject(['mongo', 'redis']);


  1. Jaromir
    16.03.2017 15:07

    Посоветуйте какой-нибудь популярный Externally Defined Dependencies загрузчик. Не могу ни одного найти


    1. Myshov
      16.03.2017 15:34

      При написании статьи, я долго пытался найти загрузчик, использующий этот подход, но увы — не нашел.


  1. egorist
    16.03.2017 15:34

    Сейчас механизм DI используется во фреймворках Angular 2...

    Но и в первом Aнгуляре DI присутствует.


    1. Myshov
      16.03.2017 15:41

      Да, конечно, у меня пример написан с использованием именно первой версии, не стал просто повторяться.


  1. NLO
    16.03.2017 16:39

    НЛО прилетело и опубликовало эту надпись здесь


    1. Myshov
      16.03.2017 16:40

      Да, это уточнение надо добавить в статью


  1. megahertz
    17.03.2017 17:00
    +1

    Спасибо за статью, очень интересно было вспомнить разом, как мы пришли к тому что имеем.


    Никогда не любил Module Pattern и AMD, они всегда казались костылями, хотя использовать все-равно приходилось, чтоб избежать еще более костыльных решений. Из смешанных в свое время очень впечатлил ExtJS 4. Также, сразу прижился CommonJS, а когда впервые услышал про ES Modules — радости не было придела. Очень жду, когда его можно будет использовать без babel, хотя-бы в node.


  1. JerryGreen
    17.03.2017 18:14
    +1

    Зачем нам надо было создавать что-то свое, когда можно было воспользоваться существующими форматами CommonJS или AMD? Дело в том, что хотя они и предоставляют возможность определения зависимостей и должный уровень изоляции кода, но у них был фатальный недостаток.