Робимо круті 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.
Для того, щоб зрозуміти як це працює, давайте напишемо наступний код:
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: {
представник: '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 для нашого компонента і переключили властивість активний 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}">&додати lt;/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 за безцінні поради ;)

Кілька корисних посилань
Джерело: Хабрахабр

0 коментарів

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