Продолжаю серию (раз, два, три, четыре) постов по реактивному фуллстек 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
Автор: zag2art