Введення в компоненти derby 0.6

    image
Продовжую серію (раз , два , три , чотири ) постів по реактивному фуллстек javascript фреймворку derbyjs . На цей раз мова зайде про компоненти (такий собі аналог деректів в ангуляр) — відмінний спосіб ієрархічної побудови интерфеса, і розбиття програми на модулі.
 
 

Загальна інформація про компоненти

Компонентами в дербі 0.6 називаються derby-шаблони, винесені в окрему область видимості. Давайте розбиратися. Припустимо у нас є такий view-файл (я для демонстрації вибрав все той же Todo-list — список справ з TodoMVC):
 
index.html
 
<Body:>

  <h1>Todos:</h1>
  
  <view name="new-todo"></view>
  
  <!-- Вывод списка дел -->
  
<new-todo:>  
  <form>
    <input type="text">
    <button type="submit">Add Todo</button>
  </form>


 
І Body: і new-todo: тут шаблони, як зробити new-todo компонентом? Для цього потрібно в дербі-додатку його зареєструвати:
 
app.component('new-todo', function(){});

То-є зіставити шаблоном якусь функцію, яка буде відповідати за нього. Простіше нікуди (хоча приклад поки ще повністю марний). Але що це за функція? Як відомо функції в javascript можуть задавати клас. Методи класу поміщаються в прототип, це тут і використовується.
 
Трохи розгорнемо приклад — прив'яжемо input до реактивної змінної і створимо обробник події on-submit. Спочатку подивимося як це було б, якби у нас не було компонент:
 
<new-todo:>  
  <form on-submit="addNewTodo()">
    <input type="text" value="{{_page.new-todo}}">
    <button type="submit">Add Todo</button>
  </form>

 
 
app.proto.addNewTodo = function(){
  //...
}

Які тут недоліки:
1. Засмічується глобальний контекст (_page)
2. Функція addNewTodo додається до app.proto — у великому додатку тут буде локшина.
 
Як буде якщо зробити new-todo компонентом:
 
<new-todo:>  
  <form on-submit="addNewTodo()">
    <input type="text" value="{{todo}}">
    <button type="submit">Add Todo</button>
  </form>

 
app.component('new-todo', NewTodo);  

function NewTodo(){}

NewTodo.prototype.addNewTodo = function(todo){
  // Обратите внимание модель здесь "scoped"
  // она не видит глобальных коллекций, только локальные
  var todo = this.model.get('todo');
  //...
}

Так, що помінялося? По-перше усередині шаблону new-todo: тепер своя область видимості, тут не видно _page і всі інші глобальні колекції. І, навпаки, шлях todo тут локальний, в глобальній області видимості він не доступний. Інакапсуляція — це здорово. По-друге функція-обробник addNewTodo тепер теж знаходиться всередині класу NewTodo незабур'янений app своїми подробицями.
 
Отже, derby-компоненти — це ui-елементи, призначення яких у приховуванні внутрішніх подробиць роботи певного візуального блоку. Тут варто відзначити те, і це важливо, що компоненти не припускають завантаження даних. Дані повинні бути завантажені ще на рівні контролера, обробного url.
 
Якщо компоненти призначені для приховування внутрішньої кухні, який же вони мають інтерфейс? Як у них передаються параметри і виходять результати?
  
Параметри передаються так само як і в звичайний шаблон через атрибути і у вигляді вкладеного html-контенту (про це трохи пізніше). Результати повертаються за допомогою подій.
 
Невелика демонстрація на нашому прикладі. Передамо в наш компонент new-todo клас і placeholder для поля введення, а введене значення будемо отримувати через подія:
 
index.html
 
<Body:>

  <h1>Todos:</h1>
  
  <view 
    name="new-todo" 
    pladeholder="Input new Todo" 
    inputClass="big"
    on-addtodo="list.add()">
  </view>
  
  <view name="todos-list" as="list"></view>

<new-todo:>  
  <form on-submit="addNewTodo()">
    <input type="text" value="{{todo}}" placeholder="{{@pladeholder}}" class="{{@inputClass}}">
    <button type="submit">Add Todo</button>
  </form>

<todos-list:>
  <!-- вывод списка дел -->

 
app.component('new-todo', NewTodo); 
app.component('todos-list:', TodosList); 

function NewTodo(){}

NewTodo.prototype.addNewTodo = function(todo){
  var todo = this.model.get('todo');
  // создаем событие, которое будет достуно снаружи
  // (в месте вызова компонента)
  this.emit('newtodo', todo);
}

function TodosList(){};

TodosList.prototype.add = function(todo){
  // Вот так событие попало из одного компонента 
  // в другой. Все правильно, именно компонент
  // отвечающий за список и будет заниматься
  // добавлением нового элемента
}


Давайте все це обговоримо і подивимося, чого добилися.
 
Наш компонент new-todo тепер приймає 2 параметра: placeholder і inputClass і повертає подія «addtodo», ця подія ми перенаправляємо компоненту todos-list, там його обробляє TodosList.prototype.add. Зверніть увагу, створюючи екземпляр компонента todos-list ми призначили йому алиас list, використовуючи ключове слово as. Саме тому в обробнику on-addtodo ми змогли прописати list.add ().
 
Таким чином new-todo повністю ізольований і ніяк не працює з зовнішньої моделлю, з іншого боку компонент todos-list повністю відповідає за список todos. Обов'язки строго розділені.
 
Тепер варто більш детально зупинитися на параметрах, переданих компоненту.
 
 

Інтерфейс компонент

Необхідно відзначити, що передача параметрів у компоненти дісталася їм у спадок від шаблонів, тому більша частина функціонал аналогічна (якщо не сказано інше, приклади я буду приводити на шаблонах).
 
Відзначимо, що шаблони (як і компоненти) в html файлах derby подібні до функцій, у них є декларація, де описаний сам шаблон. А так само є (можливо багаторазовий) виклик даного шаблону з інших шаблонів.
 
 
# Синтаксис декларації шаблону (компонента) і що таке @ content
 
<name: ([element="element"] [attributes="attributes"] [arrays="arrays"])>

Атрибути element, attributes і array є необов'язковими. Що вони означають? Розглянемо на прикладах:
 
 
Атрибут element
За замовчуванням декларація і виклик шаблону виглядають якось так:
(Ще не обр)
 
 
<!-- декларация шаблона -->
<nav-link:>
  <!-- в $render.url лежит текущий url страницы -->
  <li class="{{if $render.url === @href}}active{{/}}">
    <a href="{{@href}}">{{@caption}}</a>
  </li>  

<!-- вызов шаблона из другого шаблона, например из Body: -->
<view name="nav-link" href="/" caption="Home"></view>

 
Робити так не завжди зручно. Іноді хотілося б викликати шаблон НЕ через тег view з відповідним ім'ям, а прозоро, використовуючи ім'я шаблону як ім'я тега. Для цього і потрібен атрибут element.
 
 
<!-- декларируем шаблон, давая ему возможность вызываться как тег nav-link -->
<nav-link: element="nav-link">
  <li class="{{if $render.url === @href}}active{{/}}">
    <a href="{{@href}}">{{@caption}}</a>
  </li>  

<!-- вызов nav-link из другого шаблона, например из Body: -->
<nav-link href="/" caption="Home"></nav-link>


 
А можна навіть так
 
<nav-link href="/" caption="Home"/>


У такому варіанті, ми не використовуємо закривається частина тега, так як вміст тега у нас відсутня. А що це таке?
 
 
Неявний параметр content
 
При виклику шаблону ми використовуємо тег view, або тег іменований атрибутом element приблизно так:
 
 
<!-- так -->
<view name="nav-link" href="/" caption="Home"></view>
<!-- либо так -->
<nav-link name="nav-link" href="/" caption="Home"></nav-link>

<!-- декларация шаблона -->
<nav-link: element="nav-link">
  <li class="{{if $render.url === @href}}active{{/}}">
    <a href="{{@href}}">{{@caption}}</a>
  </li>  

 
Виявляється, при виклику, між відкриває і закриває частиною тега можна розмістити будь-який вміст, наприклад, текст або ж якийсь вкладений html. Він буде переданий всередину шаблону неявним параметром @ content. Давайте у нашому прикладі замінимо caption, використовуючи @ content:
 
 
<!-- так -->
<view name="nav-link" href="/">Home</view>
<!-- либо так -->
<nav-link name="nav-link" href="/">Home</nav-link>
<!-- или даже так -->
<nav-link name="nav-link" href="/">
  <span class="img image-home">
    Home
  </span>
</nav-link>

<!-- декларация шаблона -->
<nav-link: element="nav-link">
  <li class="{{if $render.url === @href}}active{{/}}">
    <a href="{{@href}}">{{@content}}</a>
  </li>  

 
Це дуже зручно, дозволяє приховувати подробиці і значно спрощувати код верхнього рівня.
 
Атрибути attributes і arrays мають до цього безпосереднє відношення.
 
 
Атрибут attributes
Можна уявити собі завдання, коли блок html-коду, переданого в шаблон, усередині шаблону не повинен єдиним блоком бути вставлений в певне місце. Припустимо, є якийсь віджет, який має header, footer і основний контент. Виклик його міг би бути якимось таким:
 
<widget>
  <header><-- содержимое --></header>
  <footer><-- содержимое --></footer>
  <body><-- содержимое --></body>
</widget>

А усередині шаблону widget буде якась складна розмітка, куди ми повинні мати можливість окремо вставити всі ці 3 блоку, у вигляді header , footer і body
 
Для цього і потрібен attributes:
 
<widget: attributes="header footer body">
   <!-- сложная разметка -->
   <!-- сложная разметка -->
     {{@header}}
   <!-- сложная разметка -->
   <!-- сложная разметка -->
     {{@body}}
   <!-- сложная разметка -->
     {{@footer}}
   <!-- сложная разметка -->

До речі, замість body, цілком можна було б використовувати content, адже все, що не перераховано до attributes (ну і, насправді, ще в arrays) потрапляє в content:
 
 
<Body:>
  <widget>
    <h1>Hello<h1>
    <header><-- содержимое --></header>
    <footer><-- содержимое --></footer>
    <p>text</text>
  </widget>

<widget: attributes="header footer">
   <!-- сложная разметка -->
   <!-- сложная разметка -->
     {{@header}}
   <!-- сложная разметка -->
   <!-- сложная разметка -->
     
     {{@content}}  <!-- сюда попадут теги h1 и p -->
     
   <!-- сложная разметка -->
     {{@footer}}
   <!-- сложная разметка -->

Тут є одне обмеження, все що ми перерахували до attributes повинно зустрічатися у внутрішньому блоці (вставляється в шаблон) всього один раз. А що робити, якщо нам потрібно більше. Якщо ми хочемо, наприклад, зробити свою реалізацію списку і елементів списку може бути багато?
 
 
Атрибут arrays
 
Робимо свій список, що випадає, нам хочеться, щоб вийшов шаблон брав аргументи приблизно так:
 
 
<dropdown>
  <option>первый</option>
  <option class="bold">второй</option>
  <option>третий</option>
</dropdown>  

Розмітка всередині dropdown буде досить складною, значить просто content нам не підійде. Так само не підійде attributes, тому що там є обмеження — елемент option може бути тільки один. Для нашого випадку ідеальним буде використання аттрибута шаблону arrays:
 
 
<dropdown: arrays="option/options">
  <!-- сложная разметка -->
  {{each @options}}
    <li class="{{this.class}}">
      {{this.content}}
    </li>
  {{}}
  <!-- сложная разметка -->
  

 
Як ви, напевно, помітили при декларації шаблону задається 'arrays = «option / options»' — тут два імені:
 
1. Option — так називатиметься html-елемент всередині dropdown-а при виклику
2. Options — так називатиметься масив з елементами усередині шаблону, самі елементи всередині цього масиву будуть представлені об'єктами, де всі атрибути option-а стануть полями об'єкта, а його внутрішній вміст, стане полем content.
 
 

Програмна частина компонент

Як ми вже говорили, шаблон перетворюється на компонент, якщо для нього зареєстрована функція-конструктор.
 
 
<new-todo:>  
  <form on-submit="addNewTodo()">
    <input type="text" value="{{todo}}">
    <button type="submit">Add Todo</button>
  </form>

 
app.component('new-todo', NewTodo);  

function NewTodo(){}

NewTodo.prototype.addNewTodo = function(todo){

  var todo = this.model.get('todo');
  //...
}

 
У компонента є зумовлені функції, які будуть викликані в деякі моменти життя компонента — це create, init і destroy.
 
 
# init
Функція init викликається як на клієнті, так і на сервері, до рендеринга компонента. Її призначення в тому, щоб ініціалізувати внутрішню модель компонента, задати значення за умовчанням, створити необхідні посилання (ref).
 
 
// взято из https://github.com/codeparty/d-d3-barchart/blob/master/index.js 
function BarChart() {}

BarChart.prototype.init = function() {
  var model = this.model;
  model.setNull("data", []);
  model.setNull("width", 200);
  model.setNull("height", 100);

  // ...
};

 
 
# create
Викликається тільки на клієнті після рендеринга компонента. Потрібна для реєстрації обробників подій, підключення до компоненту клієнтських бібліотек, підписок на зміну даних, запуску реактивних функцій компонента і т.д.
 
BarChart.prototype.create = function() {
  var model = this.model;
  var that = this;

  // changes in values inside the array
  model.on("all", "data**", function() {
    //console.log("event data:", arguments);
    that.transform()
    that.draw()
  });
  that.draw();
};

 
 
# destroy
 
Викликається в момент знищення компонента, потрібна для завершальних дій: звільнення пам'яті, відключення реактивних функцій, відключення клієнтських бібліотек.
 
 

Що доступно в this в обробниках компонента?

 
У всіх обробниках компонента в this доступні: model, app, dom (крім init), все аліаси до dom-елементам, і компонентам, створеним всередині компонента, parent-посилання на компонент-батько, ну і ясна річ все що ми самі помістили в prototype функції-конструктора компонента.
 
Модель тут з наведеної областю видимості. То-є через this.model у компоненту видна буде тільки модель самого компонента, якщо ж вам необхідно звернутися до глобальної області видимості derby, використовуйте this.model.root, або this.app.model.
 
C app все зрозуміло, це екземпляр derby-додатки, через нього багато що можна зробити, наприклад:
 
 
MyComponent.prototype.back = function(){
  this.app.history.back();
}

 
Через dom можна навішувати обробники на DOM-події (доступні функції on, once, removeListener), наприклад:
 
// взято https://github.com/codeparty/d-bootstrap/blob/master/dropdown/index.js
Dropdown.prototype.create = function(model, dom) {
  // Close on click outside of the dropdown
  var dropdown = this;
  dom.on('click', function(e) {
    if (dropdown.toggleButton.contains(e.target)) return;
    if (dropdown.menu.contains(e.target)) return;
    model.set('open', false);
  });
};

 
Щоб повністю зрозуміти цей приклад, потрібно мати на увазі, що this.toggleButton і this.menu — це аліаси для DOM-елементів, задані в шаблоні через as:
 
Подивіться тут: github.com / codeparty / d-bootstrap / blob / master / dropdown / index.html # L4-L11
 
Всі функції dom: on, once, removeListeners можуть брати чотири параметри: type, [target], listener, [useCapture]. Target — елемент, на який навішується (з якого знімається) обробник, якщо target не вказаний, він дорівнює document. Решта 3 параметра аналогічні відповідним параметрам звичайної addEventListener (type, listener [, useCapture])
 
Аліаси на dom-елементи всередині шаблону задаються за допомогою ключового словa as:
 
 
<main-menu:>
  <div as="menu">
    <!-- ... -->
  </div>

 
 
MainMenu.prototype.hide = function(){
  // Например так
  $(this.menu).hide();
}

 
 

Винос компонент з програми в окремий модуль

 
До цього ми розглядали тільки компоненти, шаблони яких вже були всередині будь-яких html-файлів програми. Якщо ж потрібно (а зазвичай потрібно) повністю відокремити компонент від програми робиться наступне:
 
Для компонента створюється окрема папка, в неї кладуться js, html, сss файли (з файлами стилів є невелика особливість), компонент реєструється в додатку за допомогою функції app.component в яку передається тільки один параметр — функція-конструктор. Якось так:
 
app.component (require ('… / components / dropdown'));
 
Зауважте, раніше, коли шаблон компонента вже був присутній в html-файлах додатки, реєстрація була іншою:
 
app.component ('dropdown', Dropdown);
 
Давайте розглянемо небудь приклад:
 
tabs / index.js
 
module.exports = Tabs;
function Tabs() {}
Tabs.prototype.view = __dirname;

Tabs.prototype.init = function(model) {
  model.setNull('selectedIndex', 0);
};

Tabs.prototype.select = function(index) {
  this.model.set('selectedIndex', index);
};

tabs / index.html
 
<index: arrays="pane/panes" element="tabs">
  <ul class="nav nav-tabs">
    {{each @panes as #pane, #i}}
      <li class="{{if selectedIndex === #i}}active{{/if}}">
        <a on-click="select(#i)">{{#pane.title}}</a>
      </li>
    {{/each}}
  </ul>
  <div class="tab-content">
    {{each @panes as #pane, #i}}
      <div class="tab-pane{{if selectedIndex === #i}} active{{/if}}">
        {{#pane.content}}
      </div>
    {{/each}}
  </div>

 
Варто особливу увагу звернути на рядок:
 
Tabs.prototype.view = __dirname;

Звідси derby візьме ім'я компонента (воно ж відсутня в самому шаблоні, так як там використовується 'index:'). Алгоритм простий — береться останній сегмент шляху. Припустимо _dirname у нас зараз дорівнює '/ home/zag2art/work/project/src/components/tabs', це означає що в інших шаблонах до даного компонента можна буде звернутися через 'tabs', наприклад так:
 
<Body:>
  <tabs selected-index="{{widgets.data.currentTab}}">
    <pane title="One">
      Stuff'n
    </pane>
    <pane title="Two">
      More stuff
    </pane>
  </tabs>

Саме ж підключення даного компонента до додатка буде таким:
 
app.component(require('../components/tabs'));

Дуже зручно оформляти компоненти у вигляді готельних модулів npm, наприклад, www.npmjs.org/package/d-d3-barchart
  

    
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.