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

в 12:38, , рубрики: amd, CommonJS, javascript, modules, метки: , ,

В последнее время я стал много писать на 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 библиотеки.

Автор: shai_xylyd

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js