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

в 10:40, , рубрики: html, javascript, Веб-разработка, фреймворк, шаблонизатор, метки: ,

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

MaskJS@GitHub
Небольшой todo пример для разогрева. 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 на гитхабе.

Гибкость

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. Она помогает работать с 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, то маска справится с этим точно также, и причем в разы быстрее, маленький пример

$('<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

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

 
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
Как же веб фреймворк без «привязок»? Здесь всё в лучших традициях жанра: One- / Two-way Bindings, Custom Binding Providers, Array Mutators, Validators.
Пример можно посмотреть на mask-try | bindings. Биндинги по своей природе очень производительны, так как в 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
«Горячим» обновлением ресурсов без перезагрузки страницы сейчас никого не удивишь — да и реализация довольно тривиальная, но со скриптами не всё так просто. Здесь должны учитываться замыкания, 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 за многие дельные советы. Многое попытался учесть. Также буду рад любым другим советам или пожеланиям. Если вы знаете интересные техники из других языков / фреймворков — пожалуйста, поделитесь знаниями ). А также можете присоединиться к разработке.

Удачи.

Автор: tenbits

Источник

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


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