YAMD: еще один велосипед для описания модулей в JS

от автора

В последнее время я стал много писать на 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
define([], function() { return function(a,b) {   return a + b; }; });
function add(a, b) {   return a + b; } module.exports = add;
expose(add); function add(a, b) {   return a + b; }
./math/multiply.js
define( ["math/adding"],  function(adding) { return function(a,b) {   var result = 0;   for (var i=0;i<a;i++) {     result = adding(result, b);   }   return result; }; });
var add = require('./add'); function multiply(a,b) {   var result = 0;   for (var i=0;i<a;i++) {     result = add(result, b);   }   return result; } module.exports = multiply;
expose(multiply); function multiply(a,b) {   var result = 0;   for (var i=0;i<a;i++) {     result = root.add(result, b);   }   return result; }
USAGE (assuming all dependencies are included)
require( ["math/add", "math/multiply"],  function(add, multiply) {   console.info(add(2,7));   console.info(multiply(2,7)); });
var multiply = require("./math/multiply"); var add = require("./math/add"); console.info(add(7,2)); console.info(multiply(7,2));
console.info(math.add(7,2)); console.info(math.multiply(7,2));

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
define( ["require", "math/collatz/inc", "math/collatz/dec"], function(require, inc, dec) {   return function(n) {     if (n==1) return 0;     if (n%2==0) return require("math/collatz/dec")(n);     if (n%2==1) return require("math/collatz/inc")(n);   }; });
function steps(n) {   if (n==1) return 0;   if (n%2==0) return require('./dec')(n);   if (n%2==1) return require('./inc')(n); } module.exports = steps;
expose(steps); 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); }
./math/collatz/inc.js
define( ["require", "math/collatz/steps"], function(require, steps) {   return function(n) {     return require("math/collatz/steps")(3*n+1)+1;   }; });
function inc(n) {   return require('./steps')(3*n+1)+1; } module.exports = inc;
expose(inc) function inc(n) {   return root.collatz.steps(3*n+1)+1; }
./math/collatz/dec.js
define( ["require", "math/collatz/steps"], function(require, steps) {   return function(n) {     return require("math/collatz/steps")(n/2)+1;   }; });
function dec(n) {   return require('./steps')(n/2)+1; } module.exports = dec;
expose(dec) function dec(n) {   return root.collatz.steps(n/2)+1; }

Взаимные зависимости в случае 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 библиотеки.

ссылка на оригинал статьи http://habrahabr.ru/post/190700/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *