Делаем крутые Single Page Application на basis.js. Часть 2
Продолжаю увлекательный цикл статей про создание мощных 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.
Для того, чтобы понять как это работает, давайте напишем следующий код:
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 }) }
}
});
Выбранная группа: {group}
Есть пользователь. У пользователя есть группа, в которой он состоит.
При помощи кнопок на странице, мы можем менять группу пользователя.
В результате вызова 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 предназначен как раз для того, чтобы создавать функций-заготовки для синхронизации данных.
Суть в том, что эти функции-заготовки сами знают — когда и в какое состояние необходимо переключить данные.
Давайте создадим компонент, который будет загружать данные с сервера и выводить их в виде списка текстовых полей, с возможность редактирования и удаления.
Кажется трудоемким? Отнюдь!
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();
}
}
}
});
Вот и всё, теперь осталось только набросать разметку и пробросить в нее нужные значения:
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
let STATE = require('basis.data').STATE;
let Value = require('basis.data').Value;
// ....
new Node({
// ...
binding: {
loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING)
}
// ...
});
Используем новый биндинг в шаблоне:
загружается...
Теперь, во время синхронизации набора, будет выводится надпись «загружается…»
В показанном примере, мы создаем биндинг 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-подобные объекты в качестве аргументов и функцию, которая будет выполняться, когда значение любого из переданных аргументов изменилось.
Выглядит это следующим образом:
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.
Теперь добавим созданный биндинг в разметку:
загружается...
список пуст
Теперь, если удалить все элементы из списка или с сервера придет пустой список, то на экране будет выведено сообщение — «список пуст».
Нам осталось реализовать последнюю возможность из нашего списка — добавление и сохранение элементов списка.
Здесь будем использовать уже знакомые вещи.
Для начала, добавим в разметку пару кнопок: сохранить и добавить. Таким образом, конечный вариант разметки, приобретет следующий вид:
загружается...
нет записей
Как видно из примера, кнопки должны быть заблокированы, когда биндинг disabled установлен в true.
Теперь обработаем клики по кнопкам, реализуем добавление и сохранение элементов и, наконец, посмотрим на конечный вариант кода:
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 за бесценные советы ;)
Несколько полезных ссылок
- Репозиторий с кодом из статьи
- Документация basis.js
- Исходный код basis.js