В последнее время я стал много писать на JS, сейчас работаю над сложным приложением и довольно крупной библиотекой (~5K SLoC). Конечно же, я столкнулся с проблемой модульности.
Для приложения идеально подошел AMD — указываешь в зависимостях библиотеки, добавляешь связующий код, логику… и приложение готово. Но при разработке библиотеки я столкнулся с проблемой управления внутренними зависимостями при помощи AMD или CommonJS — получается слишком много обвязок (boilerplate), особенно когда части библиотеки взаимозависимы. Поэтому я выделил еще один подход к определению модулей в JS — YAMD.
Внимание! Это не замена AMD или CommonJS, для сборки приложения я по прежнему использую AMD, просто одна из библиотек, которую я подключаю, собрана с помощью YAMD. Таким образом, YAMD является подходом к декомпозиции сложной библиотеки без внешних зависимостей на части и отдельные файлы, и инструментом для сборки этих файлов воедино.
В статье я опишу подход. От вас хочется узнать в комментариях, что вы используете для тех же задач.
YAMD
Еще один подход для определения модулей для JavaScript. В отличие от CommonJS и AMD:
- позволяет писать меньше обвязок (boilerplate code)
- хорошо поддерживает взаимно рекурсивные модули
- повторяет (насколько это возможно) модульность Java/C#
- создан для создания библиотек, а не компоновки приложений
YAMD — подход к созданию библиотеки через декомпозицию её функциональности на отдельные файлы и последующую сборку этих файлов воедино. Файл на выходе может быть оформлен как IIFE, вводящий в глобальную область видимости одно имя (название библиотеки), так в виде CommonJS или AMD обертки.
Я придерживался принципа, что использование YAMD при разработке библиотеки не должно накладывать ограничений или обязательств на пользователя библиотеки.
Применять YAMD имеет смысл когда:
- вы создаете относительно сложную библиотеку
- у библиотеки нет внешних зависимостей
Мне понять что-то проще, когда проводится аналогия с тем, что я уже знаю, поэтому описание YAMD будет дано через кросс сравнение с AMD и CommonJS.
Сравнение YAMD, AMD и CommonJS
Сравним на простом примере YAMD, AMD и CommonJS. Представим, что мы пишем математическую библиотеку.
Выделим функции библиотеки в отдельные файлы, таким образом, каталог с исходниками для всех подходов будет одинаков:
> find .
./math
./math/multiply.js
./math/add.js
Посмотрим на исходники:
AMD | CommonJS | YAMD |
./math/add.js | ||
|
|
|
./math/multiply.js | ||
|
|
|
USAGE (assuming all dependencies are included) | ||
|
|
|
AMD подход получился довольно многословным, CommonJS уменьшает кол-во обвязок за счет неявного оборачивания каждого файла в функцию, а YAMD делает еще один шаг вперед — вводит корень библиотеки root
, через который можно обращаться к любой её части без явного импорта.
Работа с YAMD
Для сборки библиотеки оформленной в YAMD стиле нужно запустить python yamd.py path/to/library
— в результате, в текущем каталоге появится файл nameOfTheLibrary.js
. Имя библиотеки задается именем каталога с исходниками, кроме того, это имя используется для добавления библиотеки в глобальную область видимости (если, конечно, не указана сборка в CommonJS или AMD модуль).
Имя каталога, а так же имена всех подкаталогов и js-файлов (до ".js") должны быть валидны с точки зрения ограничений для имен переменных в JS.
Иерархия каталогов задает иерархию модулей, а js-файлы наполняют эти модули функциями (конструкторами) — получается что-то типа пакетов и классов в Java или пространств имен и классов в C#.
Для того, чтобы добавить функцию в модуль нужно создать в каталоге соответствующем этому модулю js-файл (имя файла до .js задает имя функции), определить в нем функцию с любым именем, например add
, и в начале файла вызвать `expose` передав ей функцию, например, `expose(add);`. Весь остальной контент файла будет приватным и виден только экспортируемой функции.
Может показаться странным, что функция используется до объявления — expose(add);
, но это не магия YAMD, а легальное поведение для JS — hoisting. Но тем не менее, есть требование к тому, чтобы expose
шла в начале файла, встречалась только один раз и до её вызова не было ни одного обращения к root
.
Предыдущий пример (математическая библиотека) после сборки будет примерно эквивалентен следующему коду:
var math = (function(){
var root = {
add: function(a, b) {
return a + b;
},
multiply: function(a,b) {
var result = 0;
for (var i=0;i<a;i++) {
result = root.add(result, b);
}
return result;
}
};
return root;
})();
Допустим, мы решили усложнить нашу библиотеку, и добавить в неё распределения из теорвера. Логично их поместить в отдельный модуль (каталог), после изменений каталог с исходниками выглядит следующем образом:
> find .
./math
./math/multiply.js
./math/add.js
./math/distributions
./math/distributions/normal.js
./math/distributions/bernoulli.js
Тогда после сборки мы получим примерно следующий код
var math = (function(){
var root = {
add: function(a, b) {
return a + b;
},
multiply: function(a,b) {
var result = 0;
for (var i=0;i<a;i++) {
result = root.add(result, b);
}
return result;
},
distributions: {
normal: function() {
throw new Error("TODO");
},
bernoulli: function() {
throw new Error("TODO");
}
}
};
return root;
})();
Вернемся к `expose`, помимо функции, она конечно же может экспортировать в модуль строки, числа или объекты. Получается, что мы можем переписать предыдущий пример, поместив все распределения в один файл в корне библиотеки, а не создавая отдельный каталог:
// FILE ./math/distributions.js
expose({normal: normal, bernoulli: bernoulli});
function normal() {
throw new Error("TODO");
}
function bernoulli() {
throw new Error("TODO");
}
После сборки библиотеки будут полностью эквивалентны.
Взаимно рекурсивные модули
В YAMD возможно добавить в модуль функцию, которая использует функцию другого модуля, а та первую. Впрочем в случае CommonJS и AMD это тоже возможно, разница только в кол-ве кода. Для примера напишем функцию, вычисляющую кол-во шагов в процессе Коллатца. Как и в первом примере структура каталога не будет меняться в случае AMD, CommonJS и YAMD:
> find math/collatz
math/collatz
math/collatz/steps.js
math/collatz/inc.js
math/collatz/dec.js
А теперь код:
AMD | CommonJS | YAMD |
./math/collatz/steps.js | ||
|
|
|
./math/collatz/inc.js | ||
|
|
|
./math/collatz/dec.js | ||
|
|
|
Взаимные зависимости в случае AMD описывались согласно этому документу — нам пришлось добавить зависимость от require и использовать её для явного импорта зависимостей внутри функций.
С этим примером справились все три подхода, но он относительно простой — рекурсивная природа вылезает только при пользовательском вызове функций библиотеки, а к этому времени библиотека уже загружена. Проблема с взаимной рекурсией возникает, если при инициализации библиотеки нужно использовать функции самой библиотеки. Эта проблема хорошо разобрана в сообщении Тома.
Для борьбы с ней в YAMD была добавлена отложенная инициализация: в expose
вторым аргументом можно передать функцию (конструктор модуля), для которой гарантируется, что она будет вызвана после того, как все модули загрузятся.
Вернемся к нашему примеру, допустим мы решили ускорить работу steps
и для некоторых n
вычислить число шагов при загрузке библиотеки. Пусть у нас определен декоратор tableLookup
.
function tableLookup(table, f) {
return function(n) {
if (n in table) return table[n];
return f(n);
}
}
Тогда нам достаточно изменить файл steps.js в YAMD подходе следующем образом:
// FILE ./math/collatz/steps.js
var table = {};
expose(tableLookup(table, steps), ctor);
function ctor() {
table[3] = steps(3);
}
function steps(n) {
if (n==1) return 0;
if (n%2==0) return root.collatz.dec(n);
if (n%2==1) return root.collatz.inc(n);
}
При использовании CommonJS/AMD у нас есть два способа реализовать тоже самое:
- добавить в библиотеку явной метод инициализации
- отложить инициализацию table до первого вызова
Получается плохо — в CommonJS для решения этой задачи мы должны либо поменять API, либо нарушить single responsibility principle и добавить в steps контроль ленивости:
// FILE ./math/collatz/steps.js
var table = {};
var inited = false;
function ctor() {
table[3] = steps(3);
}
function steps(n) {
if (!inited) {
ctor();
inited = true
}
if (n==1) return 0;
if (n%2==0) return require('./dec')(n);
if (n%2==1) return require('./inc')(n);
}
module.exports = tableLookup(table, steps)
Если закрыть глаза на нарушение SRP в CommonJS и рассматривать тяжелые процессы инициализации, то оба варианта плохи тормозами, в случае YAMD, тормозами при подключении библиотеки, а в случае CommonJS, тормозами при первом вызове steps
. Но инициализация не всегда тяжелая, а если она все таки такая, то используя YAMD можно попытаться её вынести в процессы WebWorker'ов запускаемых из ctor и надеяться, что к первому запуску `steps` она уже закончится. Использовать CommonJS так же мы не можем, так как шанс запустить инициализацию у нас получится только при первом запросе, следовательно этот запрос не успеет предсчитаться и гарантированно будет подтормаживать.
Спасибо за внимание. И не забудте написать в комментариях, как вы разрабатываете сложные JS библиотеки.
Автор: shai_xylyd