- PVSM.RU - https://www.pvsm.ru -

MaskJS — как HMV* фреймворк

MaskJS — как HMV* фреймворк
Разрабатывая MaskJS вот уже больше полугода, он превратился из DOM шаблонизатора в очень мощный, но при этом производительный веб фреймворк. В статье познакомлю вас с возможно интересными подходами к разработки. Уверен, будет интересно почитать о использовании сигналов и слотов вместо DOM событий. И как компоненты делают нашу жизнь проще. Маска легко интегрируется в уже готовый проект, и даже может быть использована вместе с любым другим фреймворком. Основным же отличием наверное является render flow, где в процессе поэтапно создается Document Fragment / контроллеры / «биндинги». Собственно всю гибкость даже сложно передать, но я попробую, и приглашаю под кат.

MaskJS@GitHub [1]
Небольшой todo пример [2] для разогрева. mask-fiddle лучше всего работает на webkit-е

View

Разметка

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

p {
    div#info.dark > 'Single Child'
    button data-user='123' style='cursor:pointer;' >  span > 'Submit'
    input type=hidden value=x;
}

Как видно, мы убрали "<>" из тэгов и убрали закрывающие тэги, а вместо этого блоки выделяются привычными "{}" скобками. (Для простоты блок с одним ребёнком похож не селектор с ">" переходом, а вовсе без детей — закрывается точкой с запятой). Текст же помещается в литералы, как в javascript. Вот таким не хитрым преобразованием, мы легко сконцентрировали разметку на структуре — что собственно более востребовано в архитектуре приложения, чем избыточность html, который нацелен на разметку текста. Многих приверженцев html это удивляет, но давайте посмотрим правде в глаза — мы, и наверное также как и вы, разрабатываем приложения с множественной локализацией. Текста в представлениях у нас нету — только ключи к json с локализацией, зачем тогда спрашивается синтаксис гипертекстовой разметки?
А в отличии от шаблонов основанных на отступах, маска легко минифицируется и занимает минимум места.

Скорость

Это является основным приоритетом в разработке MaskJS — что бы предлагать максимальную производительность на мобильных устройствах. Удалось добиться скорости более-менее сопоставимой с html, a в случае с webkit движком — даже увеличить, особенно это относится к мобильным платформам. И не потому что html parsing в webkit-ах медленный, а потому, что str.charCodeAt и document.createElement очень быстрые ). И главное, накладные расходы на контроллеры / интерполяцию / dom события / data bindings в архитектуре MaskJS минимальны. В результате, нам больше не нужно компилировать шаблоны, а это уже большой плюс к наслаждению от разработки. Если интересно, несколько ссылок на jsperf.com найдете в readme на гитхабе [3].

Гибкость

MaskJS — довольно расширяемая система. Мы можем определять контроллеры к любым тегам и создавать новые, собственно вся иерархия контроллеров(HMVC) строится на этой фиче. Можем определять обработчики к любым атрибутам. Также мы можем определить утилиты, которые, при интерполяции модели в шаблоне, будут трансформировать или переопределять данные. И напомню, что маска на клиенте рендерится непосредственно в DocumentFragment, поэтому мы всегда работаем с DOM элементами.
Все контроллеры создаются через подобие IoC контейнеров, а если вы «в теме», то сами понимаете, как легко будет их переопределять или имитировать («мокать»).
У вас есть jQuery widget (или аналог) и вы устали каждый раз его инициализировать после вставки в дом?.. Например, пришел ответ от серверa, используя любой другой шаблонизатор — вы создали представление, вставили в DOM, а потом ещё прошлись по нужным элементам и проинициализировали widget-ы. С MaskJS вы создаете Тэг-Обёртку над вашим виджетом, и маска сделает всё за вас:

mask.registerHandler(':timer', Compo({
   // пример шаблона для виджета
   template: '.cotainer > .someInnerPanel',
   slots: {
       domInsert: function(){
             // этот слот будет вызван после вставки в "живой дом", 
             // на случай, если нужно производить дом-зависимые расчёты
       }
   }
   onRenderEnd: function(){
           this.$.mySuperTimer({ timespan:  this.attr.timespan << 0 });
           /** 
               Важный момент - значения атрибутов это строки, если только значение не было
               интерполировано с модели / контроллера, тогда значение может быть как любым значением,
               так и любым экземпляром класса.

              В данном примере использую левый сдвиг для преобразования строк в целое (int) число, а undefined в ноль.
          */
   }
});

// = шаблон
// ...
:timer timespan="5000";

Теперь, можно хоть сколько угодно этот тэг использовать в шаблоне, а вот инициализировать больше не нужно. Небольшой пример создание таймеров:

$.getJSON(url).done(function(collection){ 
    jmask("ul > % each=timers > li > :timer timespan='~[timespan]'; ", collection).appendTo(document.body);
});

Design Patterns

Существует множество разных архитектурных решений, но у всех есть общая цель — уменьшить связи и зависимости. В MaskJS основной акцент делается на V(View) из MVC, и мы пытаемся абстрагироваться от Модели. Маске не важно, как выглядит ваш Business Layer и откуда он «берётся». А это значит, что все классы, данные и любая бизнес логика независима от представления и контроллеров — и не только архитектурно, но и от MaskJS библиотеки в целом. Модель может быть как Data Centric (прим. — json service response), так и комплексным Domain Model. Но в любом случае она отделена и тем самым проста для разработки и тестировании.
Далее приведу маленькие примеры разных сценариев MVC, кое что будет утрировано — так что не судите строго, делаю это только в целях лучшей наглядности.

  • Вот у нас View:
    mask.render(" div > 'A' ");

  • Добавим динамичности, отобразив букву из модели ( var model = { letter: 'A' } ):
    mask.render(" div > '~[letter]'  ", model) 

    — получаем (Data)Model / View

  • Свяжем модель с представлением так, что бы при изменении буквы в моделе — представление обновлялось:
    mask.render(" div > '~[bind: letter]' ", model);

    — вот уже и Model / View / ViewModel

  • Если нам надо изменить или дополнить данные для представления — получим Model / View / Adapter:
    mask.registerHandler(':myModelAdapter', {
        renderStart: function(model){
            _extendModelFromLocalStorage(model);
       }
    });
    
    mask.render(" :myModelAdapter > div > '~[letter]' ", model);
    

  • Если нам надо отделить view от модели получаем Model / View / Presenter
    mask.registerHandler(':myPresenter', Compo({
            onRenderStart: function(model){
                    this.letter = _handle(model);
                    this.model = this;
            }
    });
    
    mask.render(" :myPresenter > div > '~[letter]' ", model);
    

  • Если надо, что бы буква изменялась на “B” при клике – привет Model / View / Controller:
    mask.registerHandler(':letterChanger', Compo({
    	events: {
    		'click: div' : function(event){
    			this.model.letter = 'B';
                            // если не испольовать биндинги - должны обновить представление сами
                            // this.$.text(‘B’);  /* this.$ = jQuery/Zepto wrapper */
    		}
    	}
    });
    mask.render(" :letterChanger > div > '~[bind: letter]'  ");
    

  • Если надо дополнить модель + реагировать на клик – и вот уже иерархия – HMVC
    mask.render(':myAdapter > :letterChanger > div > “~[bind: letter]" '); 

  • Если надо скрыть представление в контроллер, получаем обычную инкапсуляция — (это конечно не архитектурный шаблон проектирования, но очень важный момент в MaskJS)
    mask.registerHandler(':letter', Compo({ 
        template: ":letterChanger > div > '~[bind: letter]' "
    });
    mask.render(" :myAdapter > :letter; " , model);
    

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

    header {
        #logo;
        :menu;
        :userInfo; 
    }
    :viewManager {
       :userView;
       :aboutView;
    }
    
    :pageActivity;
    :notifier;
    
    :footer;
    

    Названия компонент начинается двоеточием только для лучшей семантики представлений.

Но главное в этих всех MV* — не их названия, тем более здесь все притянуто за уши (надеюсь никого не обидел?). А сама суть, то как мы создаем контроллеры разного назначения. И как видите, зависимости мы указываем непосредственно из представления — этим самым разгружая сами контроллеры и оставляем их заниматься только своими непосредственными задачами.

Component / (Controller) / (Widget)

AST

MaskDOM

«Парсер» трансформирует View в дерево «нод». По нему потом проходится «билдер» и интерполирует модель — создает HTMLElement-ы и Контроллеры (произвольных тегов). В стандартную сборку MaskJS входит ещё одна хорошая библиотека — jmask@github [4]. Она помогает работать с maskDOM деревом, в ней используется синтаксис jQuery и её удобно использовать везде, где нужно динамически создавать maskdom дерево или изменять его, например в onRenderStart компоненты:

//..
onRenderStart: function() { 
    jmask(this).tag('div').addClass('pixel').wrappAll('.container data-id=dialog');
    //  eq. == jmask(this).wrappAll('.pixel > .container data-id=dialog');
}

Если где-то вы используете jQuery для создания DOM, то маска справится с этим точно также, и причем в разы быстрее [5], маленький пример

$('<div><span></span></div>').addClass('container').data('foo','bar').children('span').text('2013').appendTo('body')

// то же с jmask
jmask('div > span').addClass('container').data('foo','bar').children('span').text('2013').appendTo(document.body)
jmask('.container foo=bar > span > "~[text]"').appendTo(document.body, { year: 2013 })
Controllers Tree

Билдер также создаёт дерево из компонент, поэтому можно через селекторы находить другие контроллеры

mask.registerHandler(':page', Compo({
    // ...
    foo: function(){
          // ...
          // функция find вернёт первый найденный компонент
          this.find(':scroller').scroller.refresh();
          this.closest(':item[data-id=5]]').bar();

          // через jmask можно находить все контроллеры
          jmask(this).find(':listItem').each(function(x) { x.bar() })
    }
});

Signals / Slots

mask-compo@github [6]

Компонент может иметь хэш объект с перечнем всех событий которые хочет обработать —

 
var _myCompo = Compo({ 
    constructor: function(){ this.name = 'C'; }, 
    events: {
        'touchstart: .pane': function(event){ this instanceof _myCompo // -> true }, 
        //...
    }
});

Но таким образом, мы привязываемся к разметке(css классам) — а это значит — привязка к реализации представления, что усложняет нам замену View. И это не есть хорошо. Во многих фреймворках можно вызывать методы контроллера непосредственно из представления, но это тоже не дело, хотя MaskJS поддерживает expressions в шаблонах — div > '~[: controllerMethod("test") ]' (замечу, что в маске реализован свой expression parser и evaluator без with/new Function /eval). Намного лучше, когда представление посылает сигналы вверх по дереву контроллеров, начиная с «владельца» элемента — а там уже, кто хочет, тот реализовывает логику.

 
mask.registerHandler(':myCompo', Compo({ 
    constructor: function(){ this.name = 'C'; }, 
    slots : {
        greet: function(sender){ 
            // sender в контексте dom событий это сам event object, иначе компонент пославший сигнал
            alert(this.name); // "C"  
            // return false - на случай, если нужно остановить передачу сигнала дальше по дереву компонент вверх.
        }, 
        //...
    }
}));
mask.render(" :myCompo > .panel x-signal='click: greet'; ");

Заметьте декларативное объявление слотов в объекте slots — этим самым, мы чётко разделяем логику контроллера, а маска сама вызовет эти обработчики, когда соответствующие сигналы будут запущены.
Дополнительно, мы сможем в любой момент слот или сигнал деактивировать, и при этом, все элементы, которые посылают этот сигнал в данной «области видимости» контроллеров, получат статус :disabled.

Pipes

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

mask.registerHandler(':userInfo', Compo({
    pipes: {
        // pipe name
        user: { 
            logout: function(){
                 this.model.authenticated = false;
            }
        }
    }
}));

// = template.mask =
menu { 
    #logout x-pipe-signal='click: user.logout' > button > 'Sign out' 
    // ....
}
section #content  {
      // возьмём объект "user" из модели и передадим нижнему шаблону 
     % use="user" > :userInfo type='brief' ;
     // ..
}
section #footer {
    // bind to "user.authenticated" prop
    %% if="user.authenticated" > % each="user.keys" > "~[.]"
}

В этом примере попытался немножко усложнить представления.

Все сигналы также можно посылать из самих контроллеров:

   this.emitIn('name', args...);       // детям
   this.emitOut('name', args...);    // родителям
   
   Compo.pipe('user').emit('logout'); // начинаем с последнего контроллера присоединившегося к "трубе".

Bindings

mask-binding@github [7]
Как же веб фреймворк без «привязок»? Здесь всё в лучших традициях жанра: One- / Two-way Bindings, Custom Binding Providers, Array Mutators, Validators.
Пример можно посмотреть на mask-try | bindings [8]. Биндинги по своей природе очень производительны, так как в render time они только сохраняют ссылки на дом элементы, и привязываются к моделе через defineProperty/ __defineSetter__. И да — вы правильно заметили, старые браузеры не поддерживаются — но переопределив стандартный провайдер, можно добиться привязки к функциям вида setX/getX, или другим шаблонам, как .get("x")/.set("x"). Собственно, если нужно, можно ограничения обойти.
Интересные моменты:

  • можем использовать выражения, пример:
     div style='height: ~[bind: item.age * index + 10]px' 

    В зависимости как будет меняться возраст или индекс — такой будет высота панели div

  • двух-направленное связывание основанные на dom events / (jquery) custom events / сигналах.
  • для того, что бы модель была целостная, двух-направленные привязки могут иметь компонент(ы) :validate. Перед тем, как присвоить значение модели, провайдер вначале проверит его, а в случае ошибки сообщит об этом пользователю и предложит вернутся к последнему верному значению. Так наша модель остаётся целостной.
    
    input #device-type type=value > :dualbind value="age" {
        :validate match="^[a-z]{2}-[d]{4}$" message=" ... pattern: xx-1234"
    }
    

  • как вы уже может заметили маска имеет стандартный компонент %, который реализует логику if/else/each/repeat/use и прочее. Так вот, в модуле bindings реализован также one-way binding для этих вещей:
    %% if="state == true" { 
           %% each=userList {
           // ... template
    
    
Разработка

Важно не только писать большие и производительные приложения, но и получать от этого максимум удовольствия. Разделив разработку на компоненты, начиная от самых маленьких(:customCheckBox) до самых больших(:inbox), мы всегда концентрируемся на необходимом. Что бы отловить баги, в системном контроллере есть атрибуты debugger и log:

.user {
     % debugger;
      .user-status > '~[bind:info.status]'
     %% log="info.status";
}

  • debugger — во время render flow мы остановимся и можем посмотреть стэк компонент, текущую модель и html-элемент.
  • log — выводим в консоль данные. Можем доступиться как к модели, так и контроллеру.

Hot Reload Plugin

… для IncludeJS [9]
«Горячим» обновлением ресурсов без перезагрузки страницы сейчас никого не удивишь — да и реализация довольно тривиальная, но со скриптами не всё так просто. Здесь должны учитываться замыкания, dom события и прочее. Мы используем IncludeJS библиотеку для загрузки всех модулей, и каждый скрипт файл может экспортировать метод reload. Также в состав IncludeJS.Builder-а входит сервер, который следит за изменения запрашиваемых файлов и через socket.io оповещает IncludeJS. Собственно сценарий довольно простой. А вот MaskJS в свою очередь, в reload плагине переопределяет mask.registerHandler — в нем он записывает все компоненты которые регистрируются и соотносит их к пути текущего скрипта. Также плагин подписывается на событие создания контроллера, и сохраняет текущую модель и ссылку на контроллер. Таким образом, когда через socket.io мы получим оповещение о изменения файла, у нас есть список названий компонент, которые этот файл создаёт, а также список экземпляров(instances). И далее дело техники — вызвать remove/dispose каждого контроллера, и проинициализировать на их места обновлённые компоненты. Используя сигналы и слоты, родителям не нужно подписываться на dom события обновлённых компонент — сигнал и так дойдёт. Если компонент загружает mask markup отдельно и мы что-то в нем изменили, тогда IncludeJS будет расценивать это, как изменение в самом customCompo.js файле. Тема IncludeJS довольно ёмкая и об всех его возможностях как-нибудь в другой раз. Но то, что архитектура MaskJS позволяет заменять компоненты на лету, сильно упрощает разработку, особенно, если компонент спрятан за N кликами(прим. где-то в диалоге).

Node.js и TODO

На данный момент MaskJS также работает в node.js. Принципы работы те же, только после создания mask dom, создается html dom, которое в свою очередь превращается в string buffer. А на клиенте все компоненты будут проинициализированы и для завершения получат нужные DOM элементы в onRenderEnd методе.
Маршрутизация завязана не на контроллерах, как во многих фрэймворках, а на представлениях. Помните? Представление само инициализирует нужные контроллеры. Тут же можно использовать Master Pages техники и прочее.
Работа в этой области ещё не закончена, нужно будет ещё разобраться с некоторыми нюансами. Но целью является то, что бы компоненты/контроллеры / виджеты работали как на клиенте (в первую очередь), но и с возможностью рендеринга на backend-е с завершением на клиенте.
Хочется ещё создать обертку из компонент над каким нибудь css фреймворком. Маска упростит разметку в разы — спрячем css классы и div wrappers ). И упростим создание меню/календарей/диалогов и т.д.

FIN

Это пожалуй все основные моменты. Многое ещё не рассказал, но надеюсь было понятно хотя бы то, о чём шла речь, ведь с меня рассказчик никакой, а материала много, поэтому сложно сконцентрироваться.
В комментариях, пожалуйста, не пишите — «посмотри на ИКС фреймворк!» — мы следим за мейнстримом, а вот более глубоким комментариям, буду очень рад.

Хотел бы ещё поблагодарить хабраюзара rma4ok [10] за многие дельные советы. Многое попытался учесть. Также буду рад любым другим советам или пожеланиям. Если вы знаете интересные техники из других языков / фреймворков — пожалуйста, поделитесь знаниями ). А также можете присоединиться к разработке.

Удачи.

Автор: tenbits

Источник [11]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/34443

Ссылки в тексте:

[1] MaskJS@GitHub : http://github.com/tenbits/MaskJS

[2] пример: http://libjs.it/mask-try/#preset:TODO

[3] гитхабе: https://github.com/tenbits/MaskJS

[4] jmask@github: https://github.com/tenbits/mask-j

[5] в разы быстрее: http://jsperf.com/dom-builder-mask-vs-jquery/4

[6] mask-compo@github: https://github.com/tenbits/mask-compo

[7] mask-binding@github: https://github.com/tenbits/mask-binding

[8] mask-try | bindings: http://libjs.it/mask-try

[9] IncludeJS: http://github.com/tenbits/IncludeJS

[10] rma4ok: http://habrahabr.ru/users/rma4ok/

[11] Источник: http://habrahabr.ru/post/179359/