Всем доброго времени суток.Продолжаю увлекательный цикл статей про создание мощных Single Page Application на basis.js.
В прошлый раз мы немного пофилософствовали, а так же познакомились с токеном — одной из важнейших вещей в basis.js.
Сегодня речь пойдет о работе с данными.
Сразу сделаю небольшое замечание.
Данный цикл представляет из себя набор мануалов, описывающих решение различных задач в области построения SPA, при помощи фреймворка basis.js.
Мануалы не ставят перед собой цель — продублировать официальную документацию, но показывают практическое применение того, что там описано.
Да и читателю хочется видеть больше конкретики и практических примеров, а не пересказ документации.
Некоторые места всё же будут описываться более подробно. В основном это те моменты, которые я считаю нужным описать по-своему.
Давайте представим ситуацию:
Вы делаете страницу с интерактивным списком:

Возможности:
- добавление/удаление записей
- сохранение записей на сервере
- после загрузки страницы, сохраненные записи автоматически загружаются с сервера и отображаются
- во время загрузки и сохранения записей, кнопки добавить и сохранить должны быть заблокированы
- во время загрузки и сохранения записей, отображается сообщение «загружается...»
- если удалить все записи, то отображается сообщение «записей нет»
Возможно, вы уже начали представлять себе, как для решения данной задачи пишете циклы, условные операторы и добавляете обработчики событий.

Чтобы доказать это, необходимо познакомиться с некоторыми концептуальными вещами в basis.js.
В basis.js есть несколько оберток для разных типов данных:
Value — обертка для скалярных значений
DataObject — обертка для объектов
Dataset — набор элементов типа DataObject
Value очень похож на Token (о котором мы говорили в прошлой статье) но имеет более богатый функционал и ряд дополнительных методов.
DataObject представляет собой объект, изменения данных в котором можно отслеживать. Помимо этого, DataObject предоставляет механизм делегирования.
Dataset предоставляет удобные механизмы для ��аботы с коллекцией объектов.
Так же, предлагаю вам обратиться к соответствующему разделу документации для более подробного знакомства с тем, что из себя представляют данные в basis.js. А сейчас мы разберем еще одну важную вещь из арсенала basis.js.
Value::query
Статический метод Value::query — одна из самых мощных фич basis.js.
Этот метод позволяет получать актуальное значение сквозь всю цепочку указанных свойств, относительно объекта, к которому применен Value::query.
Для того, чтобы понять как это работает, давайте напишем следующий код:
index.js
let Value = basis.require('basis.data').Value; let DataObject = basis.require('basis.data').Object; let Node = basis.require('basis.ui').Node; let group1 = new DataObject({ data: { name: 'Группа 1' } }); let group2 = new DataObject({ data: { name: 'Группа 2' } }); let user = new DataObject({ data: { name: 'Иван', lastName: 'Петров', group: group1 } }); new Node({ container: document.querySelector('.container'), template: resource('./template.tmpl'), binding: { group: Value.query(user, 'data.group.data.name') }, action: { setGroup1() { user.update({ group: group1 }) }, setGroup2() { user.update({ group: group2 }) } } });
template.tmpl
<div> <div> Выбранная группа: {group} </div> <div class="btn-group"> <button class="btn btn-success" event-click="setGroup1">Группа 1</button> <button class="btn btn-danger" event-click="setGroup2">Группа 2</button> </div> </div>
Есть пользователь. У пользователя есть группа, в которой он состоит.
При помощи кнопок на странице, мы можем менять группу пользователя.
В результате вызова Value::query мы получим новый Value, который будет содержать актуальное значение по указанной последовательности свойств, относительно указанного объекта.
В показанном примере мы создаем биндинг group, значением которого является имя указанной для пользователя группы.
Но мы можем переключить группу. Как в этом случае понять, что значение обновилось?
Для того, чтобы ответить на этот вопрос, необходимо копнуть глубже, в недра basis.js.
В прототипе или экземпляре любого класса basis.js можно указать специальное свойство propertyDescriptors, при помощи которого можно «сказать» методу Value::query когда он должен актуализировать свое значение.
Давайте посмотрим на то, как описан класс DataObject в исходниках basis.js:
var DataObject = AbstractData.subclass({ propertyDescriptors: { delegate: 'delegateChanged', target: 'targetChanged', root: 'rootChanged', data: { nested: true, events: 'update' } }, // ... }
Из этого следует, что, если в запросе указать свойство data, то механизм Value::query будет актуализировать значение каждый раз, при наступлении события update от этого объекта (то есть когда данные объекта будут изменены).
А теперь еще раз посмотрим на тот запрос, который мы составили:
Value.query(user, 'data.group.data.name')
Механизм Value::query разобьет указанный запрос на части и попытается пройти вглубь объекта по указанным свойствам, автоматически подписываясь на события, указанные в propertyDescriptors каждого участника пути.
Таким образом, результат вызова Value::query всегда «знает» об актуальном значении для указанного пути, относительно указанного объекта.
Состояние данных
Вернемся к нашей задаче.
Элементы нашего списка — это данные, которые можно добавлять, загружать и сохранять.
Загрузка и сохранение — это операции синхронизации данных.
В basis.js заложена концепция состояний. Это значит, что у каждого типа данных в basis.js есть несколько состояний:
- UNDEFINED — состояние данных неизвестно (состояние по умолчанию)
- PROCESSING — данные в процессе загрузки/обработки
- READY — данные загружены/обработаны и готовы к использованию
- ERROR — во время загрузки/обработки данных произошла ошибка
- DEPRECATED — данные устарели и необходимо снова синхронизировать
Мы можем переключать эти состояния в зависимости от того, что сейчас происходит.
Давайте рассмотрим последовательность действий на примере загрузки нашего списка с сервера:

Можно придумать достаточно много кейсов по применению данного механизма. Вот лишь некоторые из них:
- когда набор данных находится в состоянии PROCESSING — кнопки сохранить и добавить должны быть заблокированы
- когда набор данных находится в состоянии ERROR — показывать сообщение с ошибкой
Загрузка и сохранение данных — частые операции в SPA, поэтому для них в basis.js есть отдельный модуль basis.net.
Как было сказано ранее, необходимо переключать состояния данных в зависимости от этапа синхронизации.
Есть два варианта того, как можно переключать состояния:
- вручную, при помощи callback'ов транспорта
- при помощи basis.net.action
basis.net.action предназначен как раз для того, чтобы создавать функций-заготовки для синхронизации данных.
Суть в том, что эти функции-заготовки сами знают — когда и в какое состояние необходимо переключить данные.
Давайте создадим компонент, который будет загружать данные с сервера и выводить их в виде списка текстовых полей, с возможность редактирования и удаления.
Кажется трудоемким? Отнюдь!
index.js
let Dataset = require('basis.data').Dataset; let Node = require('basis.ui').Node; let action = require('basis.net.action'); // источник данных let cities = new Dataset({ // настриваем синхронизацию syncAction: action.create({ url: '/api/cities', success(response) { // после завершения загрузки данных, необходимо превратить полученные JS-объекты в DataObject и поместить их в набор this.set(response.map(data => new DataObject({ data }))) } }) }); new Node({ container: document.querySelector('.container'), active: true, dataSource: cities, template: resource('./template/list.tmpl'), // описываем дочерние элементы // делегатом каждого дочернего элемента будет соответсвующий элемент набора данных childClass: { template: resource('./template/item.tmpl'), binding: { name: 'data:' }, action: { input(e) { // при вводе текста в текстовое поле - обновляем соответствующий элемента данных this.update({ name: e.sender.value }); }, onDelete() { // при нажатии на кнопку "удалить" - уничтожаем элемент данных // при уничтожении элемента, он будет автоматически удален из набора this.delegate.destroy(); } } } });
Вот и всё, теперь осталось только набросать разметку и пробросить в нее нужные значения:
list.tmpl
<b:style src="./list.css" ns="my"/> <div> <div class="my:list"> <div{childNodesElement}/> </div> </div>
item.tmpl
<b:style src="./item.css" ns="my"/> <div class="input-group my:item"> <input type="text" class="form-control input-lg" value="{name}" event-input="input"> <span class="input-group-btn"> <button class="btn btn-default btn-lg" event-click="onDelete"> <span class="glyphicon glyphicon-remove"></span> </button> </span> </div>
CSS оставляю на ваше усмотрение. Но, как вы наверное уже догадались, я использую bootstrap.
Итак, мы создали набор данных cities и настроили его синхронизацию с сервером — указали, что элементы набора необходимо брать по адресу /api/cities.
Данные можно брать из любого источника, но у меня уже поднят сервер, который отдает список городов (он будет в репозитории к статье).
После получения данных, их необходимо поместить в набор.
Для этого используем метод Dataset#set. Он принимает массив из DataObject, которые нужно поместить в набор.
Но, в качестве ответа от сервера приходит массив из обычных JS-объектов и перед помещением их в набор, необходимо преобразовать эти объекты в DataObject.
Запись
this.set(response.map(data => new DataObject({ data })))
можно значительно сократить, воспользовавшись вспомогательной функцией «basis.data.wrap»:
let wrap = require('basis.data').wrap; // ... this.set(wrap(response, true));
wrap принимает на вход массив обычных объектов, а на выходе выдает массив из тех же объектов, но обернутых в DataObject.
Так же обратим внимание на то, что мы добавили свойство dataSource для нашего компонента и переключили свойство active в true.
Исходя из того, что описано в документации, у нашего набора появился активный подписчик, а значит кому-то понадобилось содержимое этого набора.
Так как изначально в наборе пусто и его состояние установлено в UNDEFINED, то сразу же после регистрации активного подписчика, набор начинает синхронизацию по указанным ранее правилам. Полученный объекты набора будут связаны с DOM-узлами представления.
Это поведение уже заложено в Node. Как только в свойстве dataSource появляется набор, Node начинает отслеживать изменения указанного набора.
Для каждого элемента набора создает дочернее представление (компонент), которое связывает с элементом набора делегированием.
Если в наборе меняется состав элементов, то меняется и визуальное представление.
Так basis.js избавляет нас от циклов и прочей логики в шаблонах, при этом обеспечивая синхронизацию данных с их визуальным представлением.
Связывание данных подразумевает, что элементы набора и их визуальное представление начинают разделять данные при помощи делегирования.
Таким образом упрощается механизм обновления элементов набора.
Теперь будем выводить надпись «загружается...» во время синхронизации набора.
Для этого будем отслеживать состояние набора и выводить надпись «загружается...» только когда набор находится в состоянии PROCESSING
index.js
let STATE = require('basis.data').STATE; let Value = require('basis.data').Value; // .... new Node({ // ... binding: { loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING) } // ... });
Используем новый биндинг в шаблоне:
list.tmpl
<b:style src="./list.css" ns="my"/> <div> <div class="my:list"> <div class="alert alert-info" b:show="{loading}">загружается...</div> <div{childNodesElement}/> </div> </div>
Теперь, во время синхронизации набора, будет выводится надпись «загружается...»
В показанном примере, мы создаем биндинг loading который должен говорить о том, идет ли сейчас процесс синхронизации или нет. Его значение будет зависеть от состояния набора данных — true, если набор находится в состоянии PROCESSING и false в ином случае.
Если для Node указан dataSource, то свойство Node#childNodesState будет дублировать состояние указанного источника данных.
Более подробно можно почитать тут.
Кстати, как видно из примера, если указать Value::query в качестве биндинга, но не указать объект, относительно которого строится указанный путь, то этим объектом становится Node, в binding которого находится Value::query.
И даже если у Node изменится источник данных, то биндинг loading всё равно будет хранить актуальное значение, основанное на том источнике данных, который установлен в данный момент. Этот факт еще раз показывает пользу от использования Value::query.
Для справки:
Value.query('childNodesState')
можно было бы заменить на
Value.query('dataSource.state')
Результат был бы тот же. Но в случае с childNodesState мы полностью абстрагируемся от источника данных и полагаемся на механизмы basis.js.
Отлично! Осталось реализовать еще несколько моментов.
Если записей в наборе нет, то покажем соответствующее сообщение.
Но сначала, давайте подумаем — в каком случае должно показываться это сообщение?
Как минимум, когда в наборе нет элементов (свойство itemCount у набора равно нулю).
Давайте создадим соответсвующий биндинг:
new Node({ // ... binding: { // ... hasItems: Value.query('dataSource.itemCount'), // ... }, // ... };
Но у нас есть промежуток времени, когда мы еще не знаем — есть в списке элементы или нет. Например, когда происходит загрузка данных с сервера. Пока данные загружаются, мы не можем точно сказать — будет там что-то или нет. Следовательно, нам не подходит вариант, при котором мы опираемся только на одно значение.
Более грамотное условие показа сообщения звучит так: показывать сообщение если синхронизация завершена и количество элементов равно нулю.
То есть значение биндинга будет зависеть от двух Value.
В basis.js такие задачи обычно решаются при помощи Expression.
Expression принимает Token-подобные объекты в качестве аргументов и функцию, которая будет выполняться, когда значение любого из переданных аргументов изменилось.
Выглядит это следующим образом:
index.js
let Expression = require('basis.data.value').Expression; // ... new Node({ // ... binding: { // ... empty: node => new Expression( Value.query(node, 'childNodesState'), Value.query(node, 'dataSource.itemCount'), (state, itemCount) => !itemCount && (state == STATE.READY || state == STATE.ERROR) ), // ... }, // ... };
Таким образом, в биндинге empty будет true, пока в наборе нет элементов и сам набор не находится в состоянии синхронизации. В ином случае, empty будет равен false.
Теперь добавим созданный биндинг в разметку:
list.tmpl
<b:style src="./list.css" ns="my"/> <div> <div class="my:list"> <div class="alert alert-info" b:show="{loading}">загружается...</div> <div class="alert alert-warning" b:show="{empty}">список пуст</div> <div{childNodesElement}/> </div> </div>
Теперь, если удалить все элементы из списка или с сервера придет пустой список, то на экране будет выведено сообщение — «список пуст».
Нам осталось реализовать последнюю возможность из нашего списка — добавление и сохранение элементов списка.
Здесь будем использовать уже знакомые вещи.
Для начала, добавим в разметку пару кнопок: сохранить и добавить. Таким образом, конечный вариант разметки, приобретет следующий вид:
list.tmpl
<b:style src="./list.css" ns="my"/> <div> <div class="navbar navbar-default navbar-fixed-top"> <div class="container"> <div class="my:buttons btn-group"> <button class="btn btn-success" event-click="add" disabled="{disabled}">добавить</button> <button class="btn btn-danger" event-click="save" disabled="{disabled}">сохранить</button> </div> </div> </div> <div class="my:list"> <div class="alert alert-info" b:show="{loading}">загружается...</div> <div class="alert alert-warning" b:show="{empty}">нет записей</div> <div{childNodesElement}/> </div> </div>
Как видно из примера, кнопки должны быть заблокированы, когда биндинг disabled установлен в true.
Теперь обработаем клики по кнопкам, реализуем добавление и сохранение элементов и, наконец, посмотрим на конечный вариант кода:
index.js
let Value = require('basis.data').Value; let Expression = require('basis.data.value').Expression; let Dataset = require('basis.data').Dataset; let DataObject = require('basis.data').Object; let STATE = require('basis.data').STATE; let wrap = require('basis.data').wrap; let Node = require('basis.ui').Node; let action = require('basis.net.action'); let cities = new Dataset({ syncAction: action.create({ url: '/api/cities', success(response) { this.set(wrap(response, true)) } }), // создаем action для сохранения данных save: action.create({ url: '/api/cities', method: 'post', contentType: 'application/json', encoding: 'utf8', // определяем данные, которые должны "уйти" на сервер body() { return { // передаем на сервер содержимое элементов набора // this указывает на набор данных, в контексте которого был вызван метод save items: this.getValues('data') }; } }) }); new Node({ container: document.querySelector('.container'), active: true, dataSource: cities, // Node#disabled - ��дно из особых свойств, значение которого автоматически пробрасывается в binding не только текущего компонента, но дочерних disabled: Value.query('childNodesState').as(state => state != STATE.READY), template: resource('./template/list.tmpl'), binding: { loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING), empty: node => new Expression( Value.query(node, 'childNodesState'), Value.query(node, 'dataSource.itemCount'), (state, itemCount) => !itemCount && (state == STATE.READY || state == STATE.ERROR) ) }, action: { // добавить новый объект в набор add() { cities.add(new DataObject()) }, save() { cities.save() } }, childClass: { template: resource('./template/item.tmpl'), binding: { name: 'data:' }, action: { input(e) { this.update({ name: e.sender.value }) }, onDelete() { this.delegate.destroy() } } } });
Метод save создается по аналогии с syncAction. Вызывается save при нажатии на кнопку сохранить.
Добавление элементов в список делается максимально просто: при нажатии на добавить достаточно просто добавить еще один объект в набор, а внутренние механизмы связывания устроят всё так, что новый элемент набора будет отображен в визуальном представлении соответствующим образом.
Как и говорилось выше — такие задачи решаются в basis.js без привлечения циклов и условных операторов. Всё реализовано на основе механизмов basis.js.
Конечно, внутри basis.js есть и циклы и условные операторы, но важно то, что basis.js позволяет нам свести их использование к минимуму. В клиентском коде и особенно в шаблонах.
Вот собственно и всё. Надеюсь было интересно и познавательно.
До следующего мануала!
Огромная благодарность lahmatiy за бесценные советы ;)
Несколько полезных ссылок:
UPD: запустили gitter-чатик по basis.js. Добавляйтесь, задавайте вопросы.
