Введение в компоненты 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" 
        plaсeholder="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="{{@plaсeholder}}" 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('addtodo', 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'


    Вызывается в момент уничтожения компонента, нужна для завершающих действий: отключения вещей типа setInterval, отключения клиентских библиотек и т.д.
    MyComponent.prototype.create = function(){
      var intervalId = setIterval myFunc, 1000
      
      this.on('destroy', function(){
        clearInterval(intervalId);
      });
    }
    


    Что доступно в 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

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Ну как, стоит ли продолжать серию?

    • +15
    • 5,6k
    • 6
    Поделиться публикацией

    Похожие публикации

    Комментарии 6

      0
      Отлично, спасибо и продолжайте!

      несколько замеченных опечаток (хотя, возможно, мне это только кажется).
      и сюда, а не в личку, вдруг кто-то уже пытается тестировать:

      Очевидная:
      pladeholder="Input new Todo"

      видимо должно быть placeholder=


      и с первого взгляда недопонятая:
      this.emit('newtodo', todo);

      здесь, видимо, должно быть событие addtodo?

        0
        Да, вы правы. Все исправил.
        0
        Спасибо за статью! А то derby совсем не торопится обновлять документацию, что меня сильно расстраивает.
        Есть пара вопрсов:
        1. Данные в компонент передаются только через шаблон? Не совсем понятно, почему я не могу сделать компонент, который будет работать с глобальной моделью.
        2. Можно ли компоненты подгружать по мере необходимости(lazy load)?
          0
          > Данные в компонент передаются только через шаблон?

          По хорошему да, но никто, на самом деле, не мешает запрашивать их уже после подключения компонента, единственное нужно понимать, что метод init синхронен, поэтому всякие-там subscribe, fetch или ajax.get и т.д. невозможно выполнить до начала рендеринга, а, следовательно, серверный рендеринг страдает.

          Ну а так вообще, если нужно получать данные уже в процессе работы компонента — нет никаких ограничений — в любом обработчике можно дотягивать что-либо из модели (fetch, subscribe), либо даже тягать что-то ajax-ом, если данные, лежат где-то на стороне.

          > Не совсем понятно, почему я не могу сделать компонент, который будет работать с глобальной моделью.

          Можете, это просто нарушит идею изолированности, а так вполне:

          в js глобальная модель доступна во всех методах компонента, например

          Dropdown.prototype.init = fucntion(){
            var myGlobalVar = this.model.root.get('_page.myGlobalVar');
          }
          
          


          Примерно то-же самое и в шаблонах. Нужно просто начинать путь с #root

          <index:>
            {{each #root._page.todos as #todo}}
              <!-- ... -->
            {{/}}
          
          


          > Можно ли компоненты подгружать по мере необходимости(lazy load)?

          Насколько я знаю — штатных средств для этого нет, но я легко могу себе представить, как это можно сделать :)
          0
          с файлами стилей есть небольшая особенность

          Забыли рассказать :-) Как вместе с шаблоном подтянуть ещё и соответствующие стили?
            0
            Да, Жень, точно — забыл.

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

            На данный момент дерби поддерживает 3 типа файлов со стилями: чистый css, less и stylus. Самым продвинутым является stylus — его-то все и используют (и создатели дерби в своих приложениях в том числе). Не знаю, как в less-е, но в stylus-е есть один очень удобный механизм замены локальных переменных глобальными.

            Сейчас я объяснию на примере, чтобы было понятно. Допустим у нас есть компонент и в файле стиля этого компонента мы определяем переменную с цветом фона $back-color ?= black. Фишка в том, что стилус (когда мы пишем ?= вместо обычного = )позволяет нам сделать это определение не жестким, а как бы поумолчанию. То есть, если мы зададим глобально $back-color = red, то фон у компонента станет красным. Это очень удобно, но для того чтобы это работало, файл стилей компонента должен быть явно импортирован в глобальном файле стилей. Обычно все так и делают.

            Глобальный файл стилей получается выглядит как-то так (index.styl):

            // Global vars
            $back-color = red
            
            // Import components
            @import './../components/*/*.styl'
            


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

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

          Самое читаемое