Как стать автором
Обновить

Путь JavaScript модуля

Время на прочтение28 мин
Количество просмотров121K


На момент написания этой статьи в JavaScript еще не существовало официальной модульной системы и все эмулировали модули как могли.

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

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

Дело в том, что раньше не думали, что на JavaScript можно делать огромные проекты, а не просто «пропатчить DOM», поэтому о модулях не думали. Да и вообще не думали о будущем. И тут Внезапно будущее нагрянуло! Все вроде-бы уже есть, а модули в JavaScript, мягко говоря, запаздывают. Поэтому разработчикам приходится крутиться и выдумывать какие-то эмуляторы модульности.

Думаю многие из вас читали прекрасную статью Addy Osmani Writing Modular JavaScript With AMD, CommonJS & ES Harmony, которая стала одной из глав его книги Learning JavaScript Design Patterns в этой статье рассказывается про «современные» JavaScript модули или же читали достаточно старую статью JavaScript Module Pattern: In-Depth 2010 года про «старые» модули.

Я не буду переводить эти статьи и не буду делать из них солянку. В своей статья я хочу рассказать о моем модульном пути. О том как я проделал путь от «старых» модулей к «новым» и что использую сейчас и почему.

Эта статья состоит из 3 частей: Путь модуля, Матчасть по кишкам модулей и Распространенные виды модулей

tl;dr
Я прошел длинный путь от «не модулей» через AMD и browserify к LMD, который удовлетворяет все мои потребности и делает жизнь проще. В будущем делаю ставку на ECMAScript 6 Modules.

Путь модуля


Этап 1: Без модулей

В те времена, когда кода JavaScript было мало я вполне обходился и без модулей. Тогда они были мне не нужны. Введение модульной системы превратили бы мои 50 строк кода в 150. И быстренько пропатчить DOM я мог и без модулей. Я вполне обходился пространствами имен и не использовал сборку, а минификаторы тогда не были развиты.

Модуль
MyNs.MyModule = function () {};
MyNs.MyModule.prototype = {
	// ...
};

Сборка
<script src="myNs.js"/>
<script src="myNs/myModule.js"/>

Прогресс моего приложения шагнул еще на пол шага вперед, когда я стал собирать свои файлы с помощью cat
$ cat js/*.js > build.js


Этап 2: Препроцессинг

Прогресс не стоит на месте и мои 50 строк кода постепенно превратились в 1500, я стал использовать сторонние библиотеки и их плагины. И приложение, которые я писал можно было вполне назвать Rich Internet Application. Деление на модули и их частичная изоляция решала мои проблемы того времени. Для сборки я стал использовать препроцессоры. Модулей было много, у них были зависимости, а разрешать зависимости руками мне не очень хотелось, поэтому препроцессинг тогда был незаменим. Я использовал пространства имен, хотя с ними было много возни:
if (typeof MyNamespace === 'undefined') {
    var MyNamespace = {};
}

и лишней писанины:
new MyNamespace.MyConstructor(MyNamespace.MY_CONST);
// vs
new MyConstructor(MY_CONST);

и минификаторы того времени плохо сжимали такой код:
new a.MyConstructor(a.MY_CONST);
// vs
new a(b);

Мои модули шагнули еще чуть-чуть вперед, когда я стал применять тотальную изоляцию и выкинул пространство имен, заменив его областью видимости. И стал использовать вот такие модули:
include('deps/dep1.js');

var MyModule = (function () {
	var MyModule = function () {};
	MyModule.prototype = {
		// ...
	};

	return MyModule;
})();

И вот такую сборку
(function () {
include('myModule.js');
}());

И тот же препроцессинг
$ includify builds/build.js index.js

Каждый модуль имеет локальную область видимости и вся сборка обернута еще одной IEFE. Это позволяет оградить модули друг от друга и все приложение от глобалов.

Этап 3: AMD


В один прекрасный день, читая Reddit, я наткнулся на статью о AMD и RequireJS.

Небольшое отступление. На самом деле идея AMD была заимствована из YUI Modules и хорошенько допилена. Для использования и декларации модулей теперь не нужно было выписывать лишние символы, конфигурирование так же стало проще.

Было
YUI().use('dep1', function (Y) {
    Y.dep1.sayHello();
});

Стало
require(['dep1'], function (dep1) {
    dep1.sayHello();
});

Познакомившись с AMD я понял, что до этого времени я все делал не так. Всего 2 функции require() и define() и все мои проблемы были решены! Модули стали сами загружать свои зависимости, появился вменяемый экспорт и импорт. Модуль разделился на 3 части (импорт, экспорт, тело модуля), которые можно было легко понять. Так же стало легко найти те ресурсы, которые ему нужны и которые он экспортирует. Код стал структурированным и более чистым!

Модуль
define('myModule', ['dep1', 'dep2'], function (dep1, dep2) {
	var MyModule = function () {};
	MyModule.prototype = {
		// ...
	};

	return MyModule;
});

Сборка
$ node r.js index.js bundle.js

Но не все так просто…

Этап 4: Разочарование в AMD

То, что я показал выше — идеальный модуль и идеальная сборка. Такого в реальном проекте не бывает. А бывает так, что зависимостей у модуля очень много. Тогда он превращается в что-то такое:
require(['deps/dep1', 'deps/dep2', 'deps/dep3', 'deps/dep4', 'deps/dep5', 'deps/dep6', 'deps/dep7'],
function (     dep1,        dep2,        dep3,        dep4,        dep5,        dep6,        dep7) {
    return function () {
        return dep1 + dep2;
    };
});

Таким модулем можно пользоваться, но с ним очень много возни. Чтобы побороть эту проблему можно переделать такой модуль на Simplified CommonJS. Еще в этом случае можно совсем не писать define() обертку и создавать честный CommonJS модули, а потом их собирать используя r.js.
define(function (require, module, exports) {
    var dep1 = require('dep1'),
        dep2 = require('dep2'),
        dep3 = require('dep3'),
        dep4 = require('dep4'),
        dep5 = require('dep5'),
        dep6 = require('dep6'),
        dep7 = require('dep7');

    return function () {
        return dep1 + dep2;
    };
});


Формат Simplified CommonJS для RequireJS «не родной», просто разработчиком пришлось его сделать. Если начать писать такие модули, то RequireJS начнет искать зависимости данного модуля регулярками.



И может что-то не найти:
require("myModule//");
require("my module");
require("my" + "Module");
var require = r;
r("myModule");

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

RequireJS, конечно, имеет для этого решение — прописать каждый такой модуль в конфиге:
({
    "paths": {
    	"myModule": "modules/myModule.js"
    }
})

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

Я стал писать честные CommonJS модули, использовать сборку через r.js даже в девелопменте. Отказ от AMD так же позволил использовать данные модули с Node.js без какой-либо магии. Я начал понимать, что данный инструмент мне в принципе подходит, но с костылями и дополнительной полировкой.

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

Какая-то часть проекта загружалась при старте (1 запрос) остальные же части догружались по требованию. И догружались они не кучей мелких запросов, а одним большим (сборка нескольких модулей в 1м файле). Это позволяло и экономить время и трафик и уменьшало риски сетевых ошибок.

Еще бывает так, что нужно сделать несколько сборок. Например, приложение с русской локалью для среды тестинг или приложение оптимизированное под IE с английским языком для корпоративной сети. Или приложение оптимизированное под iPad для Украины с отключенной рекламой. Царила анархия и копипаст…

В философии RequireJs мне не нравилось то, что require() — это универсальный завод по производству любых ресурсов. require() делает абстракцию над плагинами и уже загруженными модулями если плагин не был по какой-то причине подключен, то как-то не совсем явно загружает его, а потом с помощью него загружает ресурс.
require(['async!i18n/data', 'pewpew.js', 'text!templates/index.html'],
fucntion (data, pewpew, template) {

});

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

Этап 5: Поиск модуля

Я понял, что так жить больше нельзя… но знал, что же мне нужно:

1 Модуль должен быть CommonJS

Достаточно частый случай, когда нужо запустить один и тот же модуль и под Node.js и под JS@DOM. Чаще всего это какие-то модули не связанный с внешней средой (файловая система/DOM) или абстрагированные от нее части: шаблоны (наиболее распространенная часть), функции работы с временем, функции форматирования, локализация, валидаторы…

Когда пишешь AMD и нужно что-то реиспользовать у тебя 2 пути: переписать AMD на CJS или использовать node-require. Чаще выбирают второй вариант потому как ничего менять не нужно. НО. Тогда появляется модульная каша, странная абстракция над уже существующей системой загрузки модулей в Node.js. Мне очень не нравились AMD модули в Node.js.

CJS кроме совместимости с Node.js лишен обертки define() и лишнего отступа, форматирующего тело функции. Его require и export нагляднее и ближе к ES6 Modules чем define()-way. Сравните сами:

ES6 Modules
import "dep1" as dep1;
import "dep2" as dep2;

export var name = function () {
    return dep1 + dep2;
};

CommonJS/Modules
var dep1 = require("dep1"),
	dep2 = require("dep2");

exports.name = function () {
    return dep1 + dep2;
};

AMD
require(['dep1', 'dep2'], function (dep1, dep2) {
	return {
		name: function () {
			return dep1 + dep2;
		}
	};
});

И если так получиться, что мне придется вернуться к AMD, то это будет совсем не больно — мне нужно будет всего лишь прописать одну строчку в конфиге, чтобы r.js оборачивл мои CJS модули.

2 Сборщик модулей

Сегодня собирается все, даже если вы не пишете CoffeeScript, то вы так или иначе проверяете, собираете, сжимаете ваши скрипты.

Для адаптации CJS модуля нужна обертка, которую может делать за меня сборщик. Сборщик так же мог бы проверить меня: все ли модули существуют, не ошибся ли я в имени модуля, все ли я плагины задекларировал.

В результате сборки я хотел бы получить 1 файл, который содержит и мои модули и скрипты, необходимые для их работы.

Делить приложение на «мои скрипты» и «не мои» «во благо кэширования» (подключать код загрузчика отдельно и мой код отдельно) не имело для меня смысла потому как я пишу в основном одностраничные веб-приложения, да и кэш сегодня может вымываться за минуты. Сборка все-в-одном так же позволит избавиться от проблем совместимости с «загрузчиком модулей» при обновлении.

3 Гибкая система конфигурации: зависимости, наследование, миксины

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

Например есть конфиг prod от него наследуется конфиг dev и подменяет какие-то модули. Так же есть конфиги ru и en, которые мы можем подмешать prod+en, dev+ru. Теперь вместо всяких «common» и копипаст (prod-ru, prod-en, dev-ru, dev-en) мы имеем всего 4 «сухих» конфига: prod, dev, ru, en.

4 CLI

Это интерфейс к тому роботу, который делает половину работы за тебя. Если он очень перегруженный или нужно --писать длинные --команды-для-работы, то это начинает напрягать и влечет за собой появление Makefile и трату кучи времени на старт этого самого робота, который должен экономить время.

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

Сравните
$ tool make -f path/to/build_name.js -o path/to/build.js

и
$ tool make build_name

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

Этап 6: browserify




browserify это инструмент, позволяющий запускать любые модули Node.js в браузере.

Просто browserify main.js > bundle.js и работает.

Поработав с browserify какое-то время я осознал его истинный use-case: адаптация среды Node.js для работы в браузере. browserify прекрасен для своих целей, но не для тех реалий в который создаются веб-приложения. Когда есть не адаптированные сторонние модули, когда есть динамическая загрузка больших частей приложения. Приходилось много колдовать в консоли, чтобы все работало.

Этап 7: LMD




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

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

Вот несколько особенностей, которые легли в основу LMD:

1 Сборка из конфига

Так как наличие конфига неизбежно, то почему бы ни основываться на нем?! Поведение lmd полностью определяется конфигом в нем прописаны и модули и плагины и пути экспорта результирующего файла. Конфиги можно наследовать и миксовать с другими конфигами.

Так выглядит конфиг
{
	"name": "My Config",
	"root": "../js",
	"output": "../build.lmd.js",
	"modules": {
		"main": "index.js"
	},
	"optimize": true,
	"ie": false,
	"promise": true
}

Если у вас сотня модулей — вам не нужно прописывать каждый модуль в конфиг! Достаточно прописать «rewrite rule» для однотипных модулей.
{
	"modules": {
		"main": "index.js",
		"<%= file %>Template": "templates/*.html"
	}
}

И на крайний случай вы можете написать конфиг в виде CJS модуля и сгенерирвать все на лету.

2 Абстрактная ФС: Отсутствие привязки к файловой системе

Привязка к ФС с одной стороны это естественно и HTTP сервер может однозначно отражать файловую систему. Но стоит помнить, что в браузере нет файловой системы и HTTP сервер поставляет ресурсы, а код уже понимает, что вот данный текст по данному URL — это модуль. Ресурсы могут перемещаться, выкладываться на CDN под произвольными именами.

Введение абстрактной файловой системы позволяет делать абстракции над модулями. Например у вас есть модуль locale под которым может скрываться как locale.ru.json так и locale.en.json за счет того, что эти модули имеют одинаковый интерфейс мы можем прозрачно менять один файл другим.

Вы вольны называть ваши модули как вам угодно и подключать не думая о относительных путях. Если у вас много модулей и вы забыли какой файл скрывается под данным модулем, то вам достаточно использовать lmd info:
$ lmd info build_name | grep module_name

info:    module_name                  ✘       plain    ✘          ✘        ✘
info:    module_name                   <- /Users/azproduction/project/lib/module_name.js

3 Не перегруженный require() и плагины

Мне не нравилось, что require это фабрика, поэтому его поведение было немного переписано. Теперь просто require() загружает модули из абстрактной файловой системы и больше ничего. А require.*() будет использовать плагин * и делать же свое дело. Например, require.js() загрузит любой JavaScript файл по аналогии с $.loadScript.

Плагины нужно явно прописывать в конфиг, однако LMD поможет вам не забыть включить плагин, если вы пишете «правильный код».

Например, в этом коде LMD поможет не забывть 3 плагина: css, parallel и promise
require.css(['/pewpew.css', '/ololo.css']).then(function () {

});

А вот в этом коде только плагин js
var js = require.js;

js('http://site.com/file.js').then(function () {

});

Вы можете включать и отключать плагины, используя наследование и миксы конфигов.

4 Адаптация модулей

Бывает так, что в проекте есть какие-то файлы, которые сложно назвать модулями, но их нужно использовать как и другие модули. LMD может легко адаптировать любой файл и сделать из него CJS модуль во время сборки. Кроме этого для использования текстовых файлов(шаблоны) и JSON-файлов не нужно прописывать ни плагины (смотри плагин text для RequireJS) ни адаптеры. В отличии от того же RequireJS LMD превращает данные файлы в честные модули, а не адаптирует их с shim.

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

Будущее?




Да, конечно, это ES6 Modules. Их формат схож со многими форматами модулей из других языков и соответствуют ожиданиям новичков в JavaScript. В них есть все необходимые атрибуты модуля: импорт, экспорт, обертка модуля (на случай если нужно конкатенировать несколько файлов). Они прекрасно транслируются в CJS и AMD. Однако в том виде в котором они есть сейчас в черновике их сложно использовать в реальных проектах.

Import статический. Нужно использовать сборщик модулей, чтобы ускорить старт приложения. Импорт внешнего модуля будет блокирующим:
<script>
import {get, Deferred} from "http://yandex.st/jquery/3.0/jquery.min.js";

get('/').then(console.log.bind(console));
</script>

Это практически аналогично
<script src="http://yandex.st/jquery/3.0/jquery.min.js">
<script>
var get = $.get,
    Deferred = $.Deferred;

get('/').then(console.log.bind(console));
</script>

В свою очередь, блокировку можно снять, используя <script async/>

Динамическая загрузка модулей есть, но она сейчас не совершенная:
Loader.load('http://json.org/modules/json2.js', function(JSON) {
	alert(JSON.stringify([0, {a: true}]));
});

Надеюсь, что загрузчик модулей сможет грузить сборку из нескольких модулей. Тогда этого будет достаточно.

Стандарт сейчас активно обсуждается и то, что я вам сегодня показал, возможно завтра будет выглядеть не так (но маловероятно). Сегодня модули и синтаксис импорта/экспорта похож на тот, который вы привыкли видеть в других языках. Это хорошо так как JavaScript используют многие разработчики и им больно видеть дикие хаки вроде AMD. Сегодня одно из направлений развития ECMAScript направлено на превращение языка в своеобразный асемблер для трансляции из других языков. И модули неотъемлемая часть этого направления.

Выводы

Сегодня, можно сказать, JavaScript не имеет устоявшейся модульной системы есть только эмуляторы модульности, однако у вас есть возможность использовать синтаксис ES6 Modules и компилировать ваши модули в CJS и AMD. В JavaScript своя атмосфера, много ограничений(сетевые тормоза, трафик, лаги), которые не позволяют использовать привычные многим импорты. Проблема сборки и асинхронной загрузки так или иначе решена в популярных эмуляторах модульности, но как ее будут решать разработчики ES6 — вопрос.

Матчасть


Если вы осилили мой модульный путь, то, я думаю, вам будет интересна моя небольшая модульная классификация.

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

  • Разрешение зависимостей
    • Ручное управление
    • Зависимости прописываются в конфиге
    • Зависимости прописываются в самом модуле
    • Зависимости прописываются в модуле и в конфиге

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

  • Экспортирование из модуля
    • Хаотичный экспорт
    • Не управляемый экспорт со строгим именем
    • «Самоэкспорт» со строгим именем
    • Управляемый экспорт с произвольным именем
    • Честный import/export

  • Сбока модулей
    • Без сборки
    • Конкатенация файлов по маске
    • Препроцессинг
    • Статический анализ зависимостей
    • Сборка из конфига

  • Инициализация и интерпретация модуля
    • Инициализируется и интерпретируется при старте
    • Инициализируется при старте, интерпретируется по требованию
    • Инициализируется и интерпретируется по требованию

  • Загрузка внешних зависимостей
    • Загрузчик неуправляемого модуля
    • Загрузчик «управляемого» модуля

  • Изоляция модулей
    • Модули не изолированы
    • Модули изолированы
    • Модули тотально изолированы


Разрешение зависимостей

Каким образом сборочный инструмент или разработчик определяет какие зависимости нужно подключить/инициализировать для нормальной работы данного модуля. У зависимостей, в свою очередь, так же могут быть зависимости.

Разрешение зависимостей. Ручное управление

Управление зависимостями на плечах разработчика. Разработчик аналитически понимает какие зависимости нужно подключить.
<script src="deps/dep1.js"/>
<script src="deps/dep2.js"/>
<script src="moduleName.js"/>

И соответственно в main.js
var moduleName = function () {
    return dep1 + dep2;
};

Никаких сторонних библиотек не нужно использовать
Когда модулей не много и они все свои — это ок
Когда модулей много такой код невозможно поддерживать
Несколько файлов = несколько запросов на сервер

Подходит для «быстро накодить».

Разрешение зависимостей. Зависимости прописываются в конфиге

Зависимости прописываются во внешнем конфиге и могут наследоваться. Используя данный конфиг какой-то сборочный инструмент загружает/подключает зависимости данного модуля. Конфиг может быть написан как для конкретного модуля так и для всего проекта.

Такой конфиг используется в LMD
{
    "modules": {
        "main": "moduleName.js"
        "<%= file %>": "deps/*.js"
    }
}

И соответственно в main.js
var dep1 = require('dep1'),
    dep2 = require('dep2');

module.exports function () {
    return dep1 + dep2;
};

Модули не завязываются на файловую систему (можно дать любое имя любому файлу)
Без изменения имени модуля можно изменить его содержимое
Нужно писать такой конфиг
Нужен дополнительный инструмент/библиотека

Разрешение зависимостей. Зависимости прописываются в самом модуле

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

Такой способ использует AMD (RequireJS)
require(['deps/dep1', 'deps/dep2'], function (dep1, dep2) {
    return function () {
        return dep1 + dep2;
    };
});

Если зависимостей у одного модуля очень много, то такой синтаксис как правило деградируют до CommonJS define либо используют всякие извращения.

Извращения
require(['deps/dep1', 'deps/dep2', 'deps/dep3', 'deps/dep4', 'deps/dep5', 'deps/dep6', 'deps/dep7'],
function (    dep1,        dep2,        dep3,        dep4,        dep5,        dep6,        dep7) {
    return function () {
        return dep1 + dep2;
    };
});

Деградация до CommonJS define
define(function (require, module, exports) {
    var dep1 = require('dep1'),
        dep2 = require('dep2'),
        dep3 = require('dep3'),
        dep4 = require('dep4'),
        dep5 = require('dep5'),
        dep6 = require('dep6'),
        dep7 = require('dep7');

    return function () {
        return dep1 + dep2;
    };
});

При использовании такой деградации RequireJS ищет зависимости регулярками. Это на 95% надежный способ. Честный же способ (AST или хитрый процессинг) потребляет слишком много ресурсов (объем кода и время процессинга), но так же не покрывает всех потребностей.

Бывают случаи когда необходимо так же написать конфиг, чтобы, например, адаптировать какой-то старый модуль, который не умеет define или если какой-то «честный модуль» инициализируется динамически — require('templates/' + type) и его не может найти регулярка. Динамическая инициализация это редкая штука и в основном используется для динамической загрузки шаблонов, но не исключена.

Практически все зависимости описываются в самом файле
Конфиги асинхронно загружаются
Не нужно писать конфиг
Но иногда приходится его все-таки писать конфиг
Нужен дополнительный инструмент/библиотека

Разрешение зависимостей. Зависимости прописываются в модуле и в конфиге

Зависимости прописываются с самом файле и в специальном конфиге.

Конфиг используется любым менеджером пакетов для устранения зависимостей. Например npm и package.json
{
    "dependencies": {
        "express": "3.x",
        "colors": "*"
    }
}

И соответственно main.js
// Внешний модуль
var express = require('express');

// Локальный модуль
var dep1 = require('./deps/dep1'),
    dep2 = require('./deps/dep2');

module.exports function () {
    return dep1 + dep2;
};

Разработчик определяет список зависимостей и их версии. Менеджер пакетов загружает модули и их зависимости. Тут, в принципе, без вариантов тк менеджер ничего не знает о модуле. package.json для менеджера единственный интерфейс взаимодействия. В свою очередь каждый модуль может загружать свои части напрямую из файловой системы require('pewpew.js')

Если использовать такой подход для браузера, то выходят такие плюсы и минусы

Все зависимости описываются в самом файле
Возможно управление версиями внешних зависимостей
Такой модуль можно без проблем использовать как на сервере так и на клиенте
Нужен дополнительный инструмент/библиотека для сборки, например browserify

Доступ к зависимостям

Определяет каким образом модуль использует зависимости внутри себя, как получает доступ к необходимому модулю.

Доступ к зависимостям. Произвольный

Все модули лежат открыто в глобальной области видимости или в неймспэйсе. Каждый модуль может без каких-либо ограничений в любом месте получить доступ к любой части приложения любым способом.
var dep1 = 1;
var dep2 = 2;

alert(dep1 + dep2);

Если модулей не много и они не большие, то это ок
Если модулей много, то такой код невозможно поддерживать
Нельзя не глаз определить зависимости модуля (нужно искать имена глобальных переменных или неймспейс)

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

Доступ к модулю можно получить только через «загрузчик» — require() или объявив зависимости модуля через define()

Такой способ используется в большинстве популярных библиотек, когда в «замыкание модуля» пробрасывается функция require через которую модуль и может получать доступ к другим модулям. Так же эта функция может быть доступна глобально.
var dep1 = require('./deps/dep1'),
    dep2 = require('./deps/dep2');

alert(dep1 + dep2);

Соответственно способ с define()
require(['./deps/dep1', './deps/dep2'], function (dep1, dep2) {
    alert(dep1 + dep2);
});

Легко понять/найти зависимости
Доступ к зависимостям модерируется, можно лениво инициализировать модуль, вычислять runtime-зависимости и прочее
Можно статически определить почти весь граф зависимостей
Код немного Verbose, но это хорошая плата за поддерживаемость
Нужна дополнительная библиотека

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

Модули декларируется при написании кода и не загружаются динамически. Статический анализатор кода может однозначно понять какой набор модулей необходим для работы приложения. Так работают практически все конструкции import.
import * from "dep1";
import * from "dep2";

Так же под такой способ доступа к зависимостям можно отнести и статический AMD define()
define('module', ['./deps/dep1', './deps/dep2'], function (dep1, dep2) {

});

Статический импорт позволяет сборщикам собирать зависимости, а трансляторам ES6 Modules переделывать код в ES3-совместимый.

Возможен статический анализ (полный или частичный)
Возможна трансляция ES6 Modules
В чистом виде редко применимо

Экспортирование из модуля

Чаще всего модули предоставляют какие-то ресурсы, которыми могут пользоваться другие модули. Это могут быть данные, утилиты (формат дат, чисел, i18n и пр). Экспортирование из модуля определяет каким образом модуль говорит «я предоставляю такие-то ресурсы».

Экспортирование. Хаотичный экспорт

Модуль экспортирует что угодно, куда угодно, когда угодно
var a = 10,
    b = '';

for (var i = 0; i < a; i++) {
    b += i;
}

var dep1 = b;

Засорение глобальной области видимости
Ад и кошмар, при любом раскладе такое не поддерживается в принципе

Экспортирование. Не управляемый экспорт со строгим именем

Если немного модифицировать предыдущий способ, добавив IIFE, то мы получим данный способ. Модуль заранее знает где он будет лежать и как будет называться.
var dep1 = (function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
})();

Или же немного другой вариант
(function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    exports.dep1 = b;
})(exports);

Или именованный AMD
define('dep1', [], function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
});

Это просто
Не нужны особые инструменты для сборки и использования таких модулей (кроме AMD)
Экспортируется только нужное
Модуль знает куда он экспортируется и какое имя у него будет

Экспортирование. «Самоэкспорт» со строгим именем

В основе этого способа лежит специальная функция «регистрации модуля» ready(), которую должен вызвать модуль, когда он готов. Она принимает 2 аргумента — имя модуля и ресурсы, которые он предоставляет.
(function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    ready('dep1', b);
})();

Для загрузки зависимостей такого модуля используется функция load(), похожая на require()
load('dep1', 'dep2', function (dep1, dep2) {
    ready('dep3', function () {
        return dep1 + dep2;
    });
});

load('dep3', do.stuff);

Модуль экспортируется асинхронно и может отложить свой экспорт
Модуль не знает где будет лежать
Модуль экспортируется сам (модуль подчиняет тот модуль, который его использует)
Модуль знает свое имя и может менять его динамически
Модуль может зарегистрировать несколько модулей
Нужна специальная библиотека

Экспортирование. Управляемый экспорт с произвольным именем

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

Это CommonJS модуль
var a = 10,
    b = '';

for (var i = 0; i < a; i++) {
    b += i;
}

module.exports = b;

или анонимный AMD
define([], function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
});

Мы можем использовать любое имя во время экспорта модуля.
var dep1 = require('deps/dep1');

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

Экспортирование. Честный import/export

Такой способ декларации модулей использует каждый второй язык программирования. Достаточно давно появилась спецификация ECMAScript 6 Modules, поэтому рано или поздно такой синтаксис придет и в JavaScript.

Декларируем модуль.
module "deps" {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    export var dep1 = b;
    export var dep2 = b + 1;
}

Так же можно декларировать модуль без обвязки module {}.

Можно использовать имена по умолчанию и писать меньше
import * from "deps";

console.log(dep1);

Можно избежать конфликты имен, используя своеобразное «пространство имен»
import "crypto" as ns;

console.log(ns.dep1);

Можно экспортировать часть модуля
import {dep1} from "deps";

console.log(dep1);

Знакомые импорты из многих языков — привычно и наглядно
Это ECMAScript 6
Нужно транслировать ES6 модуль в ES3-совместимый код, например использовать модули из TypeScript

Сбока модулей

Сегодня собирается практически все и модули в том числе. Даже если вы не используете CoffeeScript и AMD, то вы в любом случае собираете ваш проект: конкатенируете файлы, сжимаете их.

Без сборки

Все в HTML
<script src="deps/dep1.js"/>
<script src="deps/dep2.js"/>
<script src="moduleName.js"/>

Это просто
При увеличение количества модулей приложение перестает быть поддерживаемым и начинает тормозить из-за увеличения числа запросов
Смешение сущностей HTML и декларация модуля
Новая сборка — новый .html

Сборка модулей. Конкатенация файлов по маске

Собираем
$ cat **/*.js > build.js

Используем
<script src="build.js"/>

Это достаточно просто
Загружается только 1 файл
Для каждого типа сборки нужно создавать новые скрипты
Файлы могут собираться в произвольном порядке в разных OS и FS

Сборка модулей. Препроцессинг

Способ заключается в поиске специальных «меток» в файлах — include('path/name.js') или // include path/name.js и подобных
include('deps/dep1.js');
include('deps/dep2.js');

var moduleName = function () {
    return dep1 + dep2;
};

Все это разворачивается специальной утилитой в такой формат.
/* start of deps/dep1.js */
var dep1 = 1;
/* end of deps/dep1.js */

/* start of deps/dep2.js */
var dep2 = 2;
/* end of deps/dep2.js */

var moduleName = function () {
    return dep1 + dep2;
};

Соответственно у вложенных модулей могут быть еще зависимости и они также будут загружены рекурсивно.

Собирается только 1 файл
Можно сделать какое-никакое «наследование конфигов»
Для каждого типа сборки нужно создавать новый файл с перечислением всех include
Если препроцессор глупый то возможен дубликат кода и другие артефакты
При неправильном использовании возможна инъекция кода в модуль

Инъекция кода в модуль ведет к нарушению целостности модуля, влечет проблемы с "use strict", конфликтом имен и прочим неприятностям.

Вот типичный пример
(function () {
    "use strict";
    var i = 3;
    include('dep1'); // Не корректное подключение зависимости
    return dep1 + i;
})();

И его зависимость
var i = 4,
    dep = 01234;

Думаю, вы поняли последствия ;-)

Сборка модулей. Статический анализ зависимостей

Статический анализ контента модуля с поиском зависимостей. Такой способ использует r.js (сборщик RequireJS модулей) и browserify (адаптор CommonJS модулей и Node.js инфраструктуры под браузер). Они используют AST парсер, ищут вызовы define/require и таким образом находят зависимости и в отличии от include помещают эти зависимости вне модуля.

Например вот такой модуль
require(['dep1', 'dep2'], function (dep1, dep2) {
    return function () {
        return dep1 + dep2;
    };
});

если его прогнать через r.js будет переделан вот в такой вид
define('dep1', [], function () {
    return 1;
});

define('dep2', [], function () {
    return 2;
});

require(['dep1', 'dep2'], function (dep1, dep2) {
    return function () {
        return dep1 + dep2;
    };
});

browserify ведет себя подобным образом, но собирает в формат посложнее

Собирается только 1 файл
Все зависимости прописаны в самом модуле
Для каждого типа сборки нужно создавать новый файл или делать магию с симлинками
Препроцессор может не найти некоторые зависимости (Динамически конструируемые имена модулей)
Для исправления предыдущего пункта нужно писать конфиг, чтобы включить эти модули

Сбока модулей. Сборка из конфига

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

Такой способ использует LMD.
{
	"root": "../js",
    "modules": {
        "main": "main.js",
        "dep1": "deps/dep1.js",
        "dep2": "deps/dep2.js"
    }
}

Вариант, конечно, интересный, но зачем 2 раза писать одно и то же и в модуле и в конфиге?!

Это легко объясняется. LMD не знает о файловой системе, и конфиг фактически является абстрактной файловой системой. Это позволяет не задумываться об относительных путях и во время переноса/переименования модуля не бегать и не менять пути по всему проекту. Используя абстрактную ФС становится возможным получить дешевую Dependency Injection для локализации, подмены конфигов среды и прочих оптимизаций. Еще бывает так, что модули подключаются динамически и статический анализатор не может их найти физически, поэтому приходится вносить запись о модуле в конфиг. Понятно, что прописывать каждый раз модуль в конфиг это шаг назад, поэтому в LMD имеется возможность подключать целые директории с сабдиректориями, используя glob-инг и своеобразный rewrite rule.

Вот этот конфиг идентичен предыдущему
{
    "root": "../js",
    "modules": {
        "<%= file %>": "**/*.js"
    }
}

Вы определяете какие файлы нужны, а потом пишете шаблон и тем самым говорите как их нужно представить этот модуль LMD. Для определения имени LMD использует шаблонизатор из lodash, поэтому можно писать и более хитрые конструкции:
{
	"root": "../js",
    "modules": {
        "<%= file %><%= dir[0][0].toUpperCase() %><%= dir[0].slice(1, -1) %>": "{controllers,models,views}/*.js"
    }
}

Итоги этого способа такие:

Наглядно — все дерево проекта можно описать в одном файле
Надежно — исключены ошибки анализатора
Абстрактная файловая система
Нужно писать конфиг
Нужен сборщик

Инициализация и интерпретация модуля

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

Функция не инициализирована и не интерпретирована. Инициализируется только JavaScript строка.
'function a() {return Math.PI;}';

Функция инициализирована.
function a() {
	return Math.PI;
}

Функция инициализирована и интерпретирована.
function a() {
	return Math.PI;
}

a();

Каждая декларация функции и ее вызов занимает какое-то время, особенно на мобильных, поэтому хорошо бы уменьшить это время.

Инициализируется и интерпретируется при старте

Модуль поставляется как есть и выполняется при старте программы. Даже если он нам не нужен прям сейчас. Как видите в модуле есть какие-то циклы, которые могут замедлить работу.
var dep1 = (function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
})();

Не нужно использовать дополнительные средства
Если код не большой, то время инициализации не существенно
При увеличении объема кода начинает проявляться Startup Latency

Инициализируется при старте, интерпретируется по требованию

Достаточно популярный сейчас способ, который используют и AMD и модули в Node.js
define('dep1', [], function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
});

Этот модуль будет инициализирован при старте. Но его тело будет выполнено по требованию, а результат return b; закэширован и при следующем вызове интерпретация проходить не будет.

Не нужно особо сильно менять вид модуля
Startup Latency существенно сокращается при большом объема кода
Нужна дополнительная библиотека

Инициализируется и интерпретируется по требованию

Небольшая модификация предыдущего метода, позволяющая отложить инициализацию кода. Используется, в основном, для оптимизации загрузки кода на мобильных устройствах. Такую оптимизацию можно сделать для RequireJS и для LMD.

Кусок сборки LMD (не конфиг)
{
	'dep1': '(function(){var a=10,b="";for(var i=0;i<a;i++){b+=i;}return b;})'
}

Когда какой-то модуль потребует ресурсы модуля dep1, то LMD интерпретирует и инициализирует этот код.

Примерно вот так так:
var resources = new Function('return ' + modules['dep1'])()(require, module, exports);

Время инициализиции кода через new Function может быть немного медленнее, чем через честную инициализацию, но если такую оптимизацию применять с умом, то мы можем выиграть время при старте. Порожденный код через new Function, в отличии от eval(), может быть оптимизирован JIT-компилятором.

Эта операция прозрачна для разработчика
Нужна дополнительная библиотека
Нужно правильно применять

Загрузка внешних зависимостей

Как я уже сказал, в JavaScript@DOM своя атмосфера, поэтому привычные способы загрузки модулей тут не работают. Модули лежат удаленно и их синхронная загрузка не реальна. Если в десктопном приложении мы можем синхронно прилинковать библиотеку «со скоростью света», то в JavaScript@DOM такое вряд-ли реально из-за блокировки EventLoop.

Загружать все сразу мы так же не можем, поэтому приходится что-то придумывать и страдать :)

Загрузчик неуправляемого модуля

Под неупровляемым модулем я понимаю просто любой код, который не требует какой-то дополнительной обработки. Таким загрузчиком, например, является jQuery.getScript(file)

Делает он примерно следующее:
var script = document.createElement('script');
script.src = file;
script.onload = done;
document.head.appendChild(script);

Если загружать несколько модулей одновременно, то выполнятся они в порядке загрузки. Бывает так, что нужно выполнить модули в порядке их перечисления. Библиотека LAB.js, например, использует XHR для одновременной загрузки кода скриптов, а потом выполняет этот код последовательно. XHR, в свою очередь, вносит свои ограничения.
$LAB
.script("framework.js").wait()
.script("plugin.framework.js");

Остальные загрузчики, вроде YepNope и script.js делаю примертно то же самое.

Дешевое решение
Могут быть ограничения со стороны XHR или дополнительной писанины

Загрузчик «управляемого» модуля

Любая взрослая модульная система поставляется с собственным загрузчиком и может загружать любые модули и их зависимости. Например, это делает функция require() и define() из RequireJS.

Функция require() из RequireJS загрузит необходимые зависимости и зависимости зависимостей и выполнит код этих модулей в указанном порядке.
require(['dep1', 'dep2'], function (dep1, dep2) {
    console.log(dep1 + dep2);
});

В LMD, например, есть такое понятие как бандл — несколько модулей, собранных в один файл. При загрузке этого бандла все его модули становятся доступны любому модулю.
_e4fg43a({
	'dep1': function () {
		return 1;
	},

	'dep2': 2,

	'string': 'Hello, <%= name %>!'
});

require.bundle('name').then(function () {
	// do stuff
});

Управление как загрузкой модулей так и их инициализацией
Практически прозрачная для разработчика загрузка
Требует дополнительных инструментов и конфигурации

Изоляция модулей

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

Модули не изолированы

Модуль или какие-то его части доступны глобально, любой разработчик из любого места может взять и использовать.
var dep1 = (function () {
    var a = 10,
        b = '';

    for (var i = 0; i < a; i++) {
        b += i;
    }

    return b;
})();

Опять же это просто
Не нужны инструменты
Нужно задумывать о пространствах имен
Нет разделения труда у модуля. Он и делает свое дело он же и управляет получением зависимостей

Модули изолированы

Модуль не доступен глобально, но его можно получить зная имя — require('pewpew'). Скрытие, как я уже сказал, это не цель модульной системы, а следствие. В AMD есть 2 функции с помощью которых можно так или иначе получить доступ к модулю — это require() и define(). Достаточно только знать кодовое имя модуля, чтобы получить его ресурсы.
define('dep3', ['dep1', 'dep2'], function (dep1, dep2) {
    return function () {
    	return dep1 + dep2;
    };
});

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

Модули тотально изолированы

Цель таких модулей сделать так, чтобы нельзя было достучаться до модуля извне. Думаю, многие уже видели такие «модули», вот, например:
$(function () {
	var dep1 = (function () {
		var a = 10,
			b = '';

		for (var i = 0; i < a; i++) {
			b += i;
		}

		return b;
	})();

	$('button').click(function () {
		console.log(dep1);
	});
});

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

Достаточно обернуть такие модули в IEFE:
(function () {
/* start of deps/dep1.js */
var dep1 = 1;

/* start of deps/dep2.js */
var dep2 = 2;

var moduleName = function () {
    return dep1 + dep2;
};
})();

Такой способ сборки использует, например, jQuery.

LMD и browserify так же тотально изолируют модули от окружающей среды, но в отличии от сборки «все-в-одном» их модули изолируются от друг друга и от «управляющей части» сборки.

Собираются они примерно вот в такую структуру:
(function (main, modules) {
	function lmd_require() {}
	// ...
	main(lmd_require);
})
(function (require) {
	var dep1 = require('dep1');
	// ...
}, {
	dep1: function (r,m,e) {}
});

В простом случае тотальной изоляции можно легко добиться
Для остальных случаев нужны дополнительные инструменты

Сравнительная таблица популярных эмуляторов модулей в JavaScript


AMD,YUI ES6 CJS/LMD IEFE
Разрешение зависимостей В модуле+конфиг В модуле В конфиге Ручное
Доступ к зависимостям Динамический Декларативный Динамический Произвольный
Экспорт С произвольныйм именем Честный import/export С произвольныйм именем Хаотичный/Неуправляемый
Сбока модулей Статический анализ Не нужна/Конкатенация Сборка из конфига Конкатенация
Интерпретация модуля По требованию Нативное решение По требованию При старте
Изоляция модулей Изолированы Изолированы Тотально изолированы Не изолированы

Распостраненные форматы модулей


И напоследок немного справочной информации по существующим сегодня «эмуляторам» модульности в JavaScript.

No module

var moduleName = function () {
    return dep1 + dep2;
};

Namespace

var MyNs.moduleName = function () {
    return MyNs.dep1 + MyNs.dep2;
};

IIFE return

var moduleName = (function (dep1, dep2) {
    return function () {
        return dep1 + dep2;
    };
}(dep1, dep2));

IIFE exports

(function (exports, dep1, dep2) {
    exports.moduleName = function () {
        return dep1 + dep2;
    };
}(window, dep1, dep2));

AMD

YUI модули семнтически схожи с AMD. Не буду их демонстрировать.
define(["dep1", "dep2"], function (dep1, dep2) {
    return function () {
        return dep1 + dep2;
    };
});

AMD обертка для CommonJS

define(function (require, module, exports) {
    var dep1 = require('dep1'),
        dep2 = require('dep2');

    module.exports = function () {
        return dep1 + dep2;
    };
});

CommonJS

var dep1 = require('dep1'),
    dep2 = require('dep2');

module.exports = function () {
    return dep1 + dep2;
};

UMD

Видно, что сейчас есть минимум 3 формата модулей, которые нужно поддерживать. Одно дело если вы пишете свой проект и можете писать на чем угодно. Другое же дело Open-Source проекты в которых хорошо бы поддерживать все форматы. Все эти модули это всего лишь разные обертки, которые по сути делают одно и то же — забирают ресурсы и предоставляют ресурсы. Не так давно появился проект UMD: Universal Module Definition, который «стандартизировал» универсальную обертку под все форматы.
(function (root, factory) {
    if (typeof exports === 'object') {
        // Формат 1: CommonJS
        factory(exports, require('dep1'), require('dep2'));
    } else if (typeof define === 'function' && define.amd) {
        // Формат 2: AMD (анонимный модуль)
        define(['exports', 'dep1', 'dep2'], factory);
    } else {
        // Формат 3: Экспорт в глобалы
        factory(window, root.dep1, root.dep2);
    }
})(this, function (exports, dep1, dep2) {

    // Экспортируем
    exports.moduleName = function () {
        return dep1 + dep2;
    };
});

Понятно, что в разработке такое использовать как-то странно, но на «экспорт» самое то.

Почитать


  1. JavaScript Module Pattern: In-Depth
  2. Creating YUI Modules
  3. Writing Modular JavaScript With AMD, CommonJS & ES Harmony
  4. Why AMD?
  5. AMD is Not the Answer
  6. Why not AMD?
  7. Proposal ES6 Modules
  8. Playing with ECMAScript.Harmony Modules using Traceur
  9. Author In ES6, Transpile To ES5 As A Build-step: A Workflow For Grunt

Ошибки и опечатки шлите, пожалуйста, в ЛС.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какие модули вы используете в браузерном JavaScript?
30.81% Не использую модули244
21.59% Модули на основе пространств имен171
14.39% Модули на основе IIFE (jQuery-style)114
31.19% AMD или YUI247
14.39% CommonJS (browserify, lmd, ...)114
7.7% Другой61
Проголосовали 792 пользователя. Воздержался 321 пользователь.
Теги:
Хабы:
Всего голосов 188: ↑185 и ↓3+182
Комментарии71

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань