Делаем крутые Single Page Application на basis.js. Часть 2

в 21:47, , рубрики: basis.js, framework, html, javascript

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

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

Давайте представим ситуацию:
Вы делаете страницу с интерактивным списком:

Делаем крутые Single Page Application на basis.js. Часть 2 - 2

Возможности:

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

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

Делаем крутые Single Page Application на basis.js. Часть 2 - 3

Чтобы доказать это, необходимо познакомиться с некоторыми концептуальными вещами в 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 — данные устарели и необходимо снова синхронизировать

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

Делаем крутые Single Page Application на basis.js. Часть 2 - 4

Можно придумать достаточно много кейсов по применению данного механизма. Вот лишь некоторые из них:

  • когда набор данных находится в состоянии 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 за бесценные советы ;)

Несколько полезных ссылок

Автор: smelukov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js