Warp9 — еще одна реактивная js библиотека. На этот раз компонуемая и без утечек

в 12:16, , рубрики: angular, javascript, knockout, React, Веб-разработка, функциональное программирование, метки: , ,

Warp9 — еще одна реактивная js библиотека. На этот раз компонуемая и без утечек

Существует множество реактивных и около-реактивных библиотек для создания графического интерфейса на js: Angular, Knockout, React, RxJS… Спрашивается, зачем писать еще одну. Оказывается, во всех них, помимо фатального недостатка, есть еще несколько.

AngularJS Knockout React Elm RxJS, Bacon ReactiveCoffee Warp9
template based yes/no[1] yes no no no no
controls composability no[2][3] no[2] yes yes yes yes
modularization of controls yes/no[4] no yes maybe yes yes
2 way binding yes/no[5] yes no yes yes yes yes
accidental memory leaks no yes no no no yes no
todomvc yes yes yes no maybe yes/no[6] yes
headless no yes no no yes yes yes
pure js yes yes yes[7] no yes no yes
mixing markup and logic yes/no[8] no yes yes yes yes
simplicity no yes yes yes yes yes yes
rich reactive list no no yes no no yes

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

Расшифровка легенды и ремарки

template based
Являются ли шаблоны основным приемом при построении интерфейсов.

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

controls composability
Предоставляет ли библиотека механизм для сознания контролов через композицию существующих (принцип разделяй и властвуй).

[2] В AngularJS и Knockout есть механизмы для включения шаблонов (ng-include, template-binding), кроме того шаблоны можно класть в разные файлы, но из этого не следует поддержка композиции контролов и модульности. Проблема в том, что сложное JS приложение это не только шаблон, но и логика, а логика в AngularJS отделена от шаблонов (в шаблонов, мы должны всегда помнить о логике (на лицо сайд эффект), но если так, то принцип разделяй и властвуй не работает, следовательно, композиция не дает нам преимуществ, следовательно ее нет.

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

“React doesn't use templates… ...React is a library for building composable user interfaces.”

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

[3] Если создавать приложения используя только (!) кастомные директивы, то AngularJS поддерживает и controls composability, и modularization of controls.

modularization of controls
Можно ли оформить контролы так, чтобы они были самодостаточны и их можно было бы распространять независимо от приложения. Так как в web’е системы дистрибьюции есть только для javascript (requirejs, commonjs), то этот вопрос следует читать так, можно ли контролы завернуть в requirejs или commonjs.

[4] Только если контролы AngularJS оформлять как кастомные директивы.

2 way binding
Возможно ли связать две переменные или переменную и контрол так, чтобы при изменении переменной изменялась и вторая (или контрол), и наоборот.

[5] В AngularJS возможен биндинг между контролами и переменной, но невозможен между переменной и переменной.

accidental memory leaks
Легко ли нарваться на утечки памяти в приложении, написанном при использовании данной библиотеки. Так получилось, что ответ 'да' так же означает, что код с утечками выглядит значительно проще, чем эквивалентный код без утечек, а значит, если использовать эти библиотеки правильно, то они теряют часть своей элегантности.

Я написал простое приложение на Knockout и ReactiveCoffee, чтобы продемонстрировать, насколько легко допустить утечки при их использовании, собственно,приложение на Knockout иприложение на ReactiveCoffee.И, конечно же, контрольноеприложение без утечек на Warp9.

todomvc
TodoMVC является стандартным hello world’ом для разнообразных gui библиотек на js, правилом хорошего тона считается иметь свою реализацию приложения в арсенале примеров.

[6] TodoMVC на ReactiveCoffee реализован только частично: нет переключения вкладок (All, Active, Completed) и нет работы с local storage.

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

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

[7] Создатели React предлагают использовать диалект javascript’а с поддержкой встроенной html-подобной разметки (подобно поддержки xml в scala) и компилить его в js. Использовать чистый js возможно.

mixing markup and logic
Предполагается ли, что для написания приложения нужно смешивать разметку и код.

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

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

Есть другой путь для борьбы со сложностью — выделяем независимое поведение (разметка + логика) в контрол (абстракция) и, используя средства композиции, собираем из уже существующих контролов все более сложные, вплоть до нашего приложения. Проводя аналогию с оценкой сложности алгоритмов, разделение на логику и разметку уменьшает сложность вдвое (n -> n/2), в то время как абстракция/композиция сводит её с n до ln(n).

Забавно, что если посмотреть на таблицу еще раз, видно, что те библиотеки, которые поощряют смешивание, обеспечивают композицию и модульность.

[8] При разработке директив в AngularJS сталкиваешься с необходимостьюсмешивать логику и представление.

simplicity
Мало ли кол-во сущностей, которое вводит библиотека.

Это очень субъективное мнение, но мне кажется, что количество сущностей в AngularJS слишком велико. Разработчики React разделяют это мнение:

Number of concepts to learn

  • React: 2 (everything is a component, some components have state). As your app grows, there's nothing more to learn; just build more modular components.
  • AngularJS: 6 (modules, controllers, directives, scopes, templates, linking functions). As your app grows, you'll need to learn more concepts.

rich reactive list
Практически все реактивные библиотеки предоставляют примитивы для реактивных переменных, такие как создание новой реактивной переменной связанной функцией с другой реактивной переменной. В knockout это можно сделать так:

var a = ko.observable(0);
var b = ko.compute(function() { return a() + 2 });

А в warp9 так:

var a = new Cell(0);
var b = a.lift(function(a) { return a+2; });

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

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

var list = new List([new Cell(0), new Cell(1), new Cell(2)]);
var sum = list.reduce(0, function(a,b) { return a + b; });

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

Warp9

В основе warp9 лежит очень простая идея: а давайте возьмем реактивную модель, применим к ней преобразование и получим связанный с моделью реактивный DOM. Если преобразование — простая js функция, то мы получим привычное средство композиции (композиция функций) и дистрибьюции (requirejs) из коробки.

Пример отображения реактивной модели в реактивный DOM — приложение(исходник):

// создаем реактивную модель (в данном случае список)
var model = new List();
// отображаем её в реактивный DOM
var dom = TRANSFORM(model);
// отображаем реактивный DOM
warp9.ui.renderer.render(placeholder, dom);

// изменяем модель
// и изменения отображаются на экране
model.add("React.js");
model.add("Warp9");

function TRANSFORM(model) {
    var hasItems = model.count().when(function(count) { return count>0 }, true);
    return hasItems.when(true, ["div",
        ["div", "Reactive libraries:"],
        ["div", {"css/margin-left": "10px"},
            model.lift(function(item){
                return ["div", item]
            })]
    ]);
}

Реактивные примитивы

Начнем знакомство с warp9 с реактивных примитивов, на основе которых и создана GUI часть библиотеки. В презентации достаточно подробное их описание.

Все примеры из слайдов доступны на github'е.

Подписки и утечки

Реактивная модель (observer pattern, publish-subscribe) местами очень удобна, но не стоит думать, что решая одни проблемы она не вносит других. Одна из ахилесовых пят реактивности — утечки памяти. Если мы пойдем на вики почитать про утечки памяти, то среди прочего мы увидим:

To prevent this (memory leaks), the developer is responsible for cleaning up references after use… and, if necessary, by deregistering any event listeners that maintain strong references to the object

Это проблема относится ко многим имплементациям observer'а. Её упоминает Мартин Фаулер, ей посвящены статьи.

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

Давайте посмотрим, как этот принцип реализуется на практике. Взглянем на следующий код:

var cell = new Cell(0);
var lifted = cell.lift(function(x) { return x+1; });

Этот код выглядит совершенно обычно, поэтому естественно ожидать, что cell не содержит ссылки на lifted и когда все ссылки на lifted затрутся — объект соберется сборщиком мусора. В warp9 это действительно так, а теперь посмотрим на knockout:

var cell = ko.observable(0);
var lifted = ko.computed(function(){ return cell() + 1; });

Тут cell будет содержать ссылку на lifted, что контринтуитивно. Рассмотрим код посложнее

var cell = new Cell();
var lifted = cell.lift(function(x) { return x+1; });
var dispose = lifted.onEvent(Cell.handler({
    set: function(value) { console.info(“set: ” + value); },
    unset: function() { console.info(“unset”); }
}));
cell.set(3);
cell.set(5);
cell.unset();
dispose();
cell.set(1);

Естественно было бы ожидать от него

 unset
> set: 4
> set: 6
> unset

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

Чтобы код выше стал работающем — нужно вставить «подсказку» читателю, что он может течь:

var cell = new Cell();
var lifted = cell.lift(function(x) { return x+1; });
var dispose = lifted.onEvent(Cell.handler({
    set: function(value) { console.info(“set: ” + value); },
    unset: function() { console.info(“unset”); }
}));
lifted.leak();
cell.set(3);
cell.set(5);
cell.unset();
dispose();
lifted.seal();
cell.set(1);

Мы добавили lifted.leak() и lifted.seal(), код стал выглядеть подозрительно и, магическим образом, он заработал. На самом деле, вызов leak не магия, а активация реактивной переменной. При активизации происходит подписка на свои зависимости и их активация. Таким образом, вызывая leak у одного объекта (переменной или списка), мы транзитивно активизируем все его зависимости. После того, как мы закончили работать с активизированным нами объектом мы должны оставить его в том состоянии, в котором он нам достался — вызвать метод seal.

Повторный вызов leak не приводит к повторной активизации, а всего лишь инкриминирует внутренний счетчик. Если leak был вызван n раз, то seal тоже должен быть вызван n раз; каждый вызов seal будет декриминировать внутренний счетчик и когда он достигнет 0 — произойдет фактическая дезактивация. Это было сделано, чтобы вы не думали в какой состоянии вам передают объект — вызывайте leak и seal парно и все будет хорошо.

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

Исходя из примера выше можно было догадаться, что при подписываясь на переменную мы можем ожидать событий set и unset, у списка они другие: data, add, remove

var list = new List();
list.leak();
list.onEvent(List.handler({
    data: function(items) {
        // событие вызывается время от времени, в items - все элементы
        // в списке на момент получения события; событие стоит воспринимать,
        // как "забудь все, что знал, сейчас контент списка такой"
        // items - массив объектов { key: key, value: value } где value -
        // значение вставленное в список, а key - ключ (id), по которому
        // объект можно удалить в исходном списке
    },
    add: function(item) {
        // вызывается, когда в слисок добавляется элемент, item имеет вид
        // {key: key, value:value}, value - новый элемент, key - его ключ
    },
    remove: function(key) {
        // вызывается при удалении элемента, key - его ключ
    }
}));

Разметка

Рассмотрим классическое hello world приложение — пользователь вводит свое имя, например, Denis и видит реакцию «Hello, Denis!». Для того, чтобы продемонстрировать больше возможностей я добавил еще и кнопку, которая очищает поле ввода.

Живое приложение доступно по ссылке (его исходники).

//<html>
//<body>
//    <div id="placeholder"></div>
//</body>
//<script src="../../../lib/warp9/warp9.js"></script>
//<script language="javascript" type="text/javascript">
    var Cell = warp9.reactive.Cell;

    warp9.ui.renderer.render(placeholder, NAME(new Cell("Denis")));

    function NAME(name) {
        var hello = name.when(
            function(name) { return name.length > 0; },
            function(name) { return "Hello, " + name + "!"; }
        );
        return ["div",
            ["div",
                ["input-text", {
                    "css/background": hello.isSet().when(false, "red")
                }, name],
                ["button", {"!click": function() {
                    name.unset();
                }}, "clear"]],
            hello.lift(function(hello) {
                return ["div", hello];
            })
        ];
    }
//</script>
//</html>

Из примера видно, что функция NAME отображает модель (реактивную переменную) в некоторое описание объектной модели, которое затем рендерится внутри div'а с id «placeholder». Само описание очень напоминает s-expression lisp'а, только вместо круглых скобок мы используем квадратные, можно пошутить и назвать их z-expression («z» относиться к «s» также, как "[" относиться к "(").

Грамматика z-expression примерно следующая:

explicitChildren = '["' TAG_NAME '"' (',' ATTR)? (',' child)* ']'
implicitChildren = '["' TAG_NAME '"' (',' ATTR)? (reactive-list-of node) "]
child            = node | '"' STRING '"' | INT |
                   (reactive-variable-of node) |
                   (reactive-variable-of STRING) |
                   (reactive-variable-of INT)
node             = explicitChildren | implicitChildren

где TAG_NAME — имя «html» тега (в кавычках, так как можно определять собственные теги), STRING — любая строка, INT — любой integer, ATTR — объект описывающий аттрибуты тега, css и события.

Свойства объекта ATTR отображаются в аттрибуты конструируемого DOM элемента, значением могут быть реактивные переменные (кроме, конечно, id аттрибута — оно должно быть константой). Другим исключением являются свойства, начинающиеся на "!" — они должны быть функциями и отображаются на события элемента. Так же особого отношения требуют css свойства, они могут записываться двумя способами — как в примере выше:

["input-text", {
    "css/background": name.isSet().when(false, "red")
}, name]

или через создание отдельного объекта:

["input-text", {
    "css": {
        "background": name.isSet().when(false, "red")
    }
}, name]

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

Надеюсь теперь пример выше теперь стал очевиден. Давайте его усложним: мы захотели станного — для данного списка показать столько hello world'ов, сколько в нем элементов, и использовать в качестве значений по-умолчанию значения из этого списка. Раньше я говорил, что warp9 использует композицию как основной прием при проектировании интерфейсов, поэтому это не проблема. Добавляем новую функцию

function NAMES(names){
    return ["div", names.lift(NAME)];
}

и заменяем

warp9.ui.renderer.render(placeholder, NAME(new Cell("Denis")));

на

var names = new List();
warp9.ui.renderer.render(placeholder, NAMES(names));
names.add(new Cell("Denis"));
names.add(new Cell("Lisa"));

Мы увидили, как работают принципы композиции, но, помимо этого, warp9 предоставляет отличную поддержку модульности. Однако, при создании warp9 не было написано ни строчки кода, чтобы осознано этого добиться, по сути, модульность warp9 была открыта уже после того, как библиотека была написана. Дело в том, что представление в warp9 описывается кодом, из этого следует автоматическая поддержка модульности доступная для js файлов; получается, мы без проблем можем использовать, например, AMD для дистрибьюции отдельных контролов. В качестве иллюстрации я переписал предыдущий пример со списком имен с использованием AMD: само приложение и его исходники.

Кастомные атрибуты

Пока механизм кастомных атрибутов до конца не продуман и warp9 предоставляет несколько захардкоженных.

!key:enter
Событие элемента соответствующему тегу «input-text», возникает, когда пользователь нажимает enter.

warp9:role и !warp9:changed
Атрибуты элемента соответствующему тегу «input-check». Событие warp9:changed возникает, когда меняется состояние чекбокса, в аргументе передается текущее значение (true/false). Если warp9:role равен «view», то при изменении состояния чекбокса элемент не пытается изменить переданную реактивную переменную (но само состояние изменяется вслед за переменной).

!warp9:draw
Событие есть у каждого элемента, вызывается, когда элемент отрисовывается на экране.

TodoMVC

Если вы поняли все, что я написал выше, то вы обладаете уже достаточными знаниями, чтобы понять исходники TodoMVC на warp9, кстати, ссылка на живое приложение.

Заключение

Сейчас у Warp9 нет версии, это работающий концепт, демонстрирующий:

  • Компонуемость как основной принцип построения UI
  • Модульность вплоть до дистрибьюции через requirejs
  • Бережливое отношение к утечкам памяти

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

Автор: shai_xylyd

Источник

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


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