Всем доброго времени суток.
Продолжаю увлекательный цикл статей про создание мощных 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. Добавляйтесь, задавайте вопросы.
Поделиться с друзьями
-->

Комментарии (51)


  1. Fib0na4i
    11.11.2016 01:41
    +5

    Не мог не прокомментировать, физик по образованию. По этому поводу хорошо высказался Пол Парсонс — редактор книги «Научные теории за 30 секунд».



    Цитата
    У каждого из нас есть своя излюбленная теория.
    Я это знаю не понаслышке. Когда я работал редактором ежемесячного
    научно-популярного журнала ВВС «Focus», то каждый день получал по
    электронной почте несколько таких теорий. Это были послания от чита-
    телей, утверждавших, что они раскрыли тайну черных дыр, параллельных
    миров или Большого взрыва, что они установили, откуда взялась жизнь,
    и объединили законы физики частиц. Я отвечал, благодарил за теории и
    просил прислать подтверждающие их математические выкладки. Ни
    один читатель ни разу этого не сделал.
    В этом и заключается разница между «теориями», которые мы обсужда-
    ем в повседневной болтовне (основываясь на наших слабых представ-
    лениях о предмете и весьма приблизительных расчетах), и теориями,
    старательно выстроенными учеными.




    Рекомендую автору поста к прочтению кроме вышеуказанной книжки, еще:
    Мичио Каку “Гиперпространство”
    Билл Брайсон “Краткая история почти всего на свете”

    Для справки — кванты нам преподавали на протяжении двух семестров два
    раза в неделю. Одна пара — теория, одна пара — практика.

    Завидую автору поста, «пару дней» подумал и машина времени практически готова.


    1. smelukov
      03.07.2016 11:12

      Чем вам не угодли циклы и условные операторы? :)

      Ничем. Я не призываю от них отказываться, т.к. это невозможно. Просто их отсутствие в клиентском коде дает более линейный код.

      Так в чем преимущество basis.js в этом случае?

      Как я и говорил в прошлой статье, для простых задач, basis.js может показаться избыточным. Потерпите, скоро рассматриваемые задачи усложнятся ;) мы подбираемся к ним постепенно…


      1. Akuma
        03.07.2016 11:20
        +1

        Вы уже вторую статью ухудшаете репутацию этой бибилиотеки (или фреймворка) подобными словами :)

        Если при решении простых задач он избыточен, то он будет неудобен для решения сложных задач.
        Ведь главное преимущество тех же Angular/React в том, что они дают возможность разбить сложную задачу на несколько мелких и решить их просто. В вашем случае так не получится — вы сами сказали, что мелкие задачи базисом не решают.


        1. smelukov
          03.07.2016 11:26
          +2

          вы сами сказали, что мелкие задачи базисом не решают.

          Это где я такое сказал?


          1. Akuma
            03.07.2016 11:27

            > для простых задач, basis.js может показаться избыточным
            Разве не это имелось ввиду?


            1. smelukov
              03.07.2016 11:29

              Абсолютно нет


              1. Akuma
                03.07.2016 11:38

                Ок, ошибся.


    1. RubaXa
      03.07.2016 13:16
      +4

      Давайте на чистоту, решения на Angular и тем более React будут выглядеть массивней.


      • В случае с React, вам придется организовать всю работу с данными самому, начиная от XHR;
      • С Angular часть логики будут размазана между контроллером и шаблоном, придется изучить как работает $http или ng-resource.

      В обоих случаях нужно придумать какие-то флаги для состояний при работе с данными, чтобы избежать описанной проблемы с empty.


      Открой репозиторий этого пример, там 59 строк кода, из них 8 подключение и если знать как работает Value.query, то код легко читается.


      И главное,


      было бы куда понятней и проще

      Это если вы знаете, что такое React/Angular, иначе ничего не понятно, какие-то props/state, html в JS (?! который не поддерживает всех свойств, а вместо class -> className) или магический DI в Angular (в купе с неймингом factory/service) и $apply в придачу.


      Не зная документации, всё страшно.


      P.S. Если что, basis не использую, но статьи мне нравятся, а именно организация работы с потоками данных, с нетерпением жду продолжения.


      1. Akuma
        03.07.2016 14:03

        Не стану тут развивать холивар по всем этим библиотекам/фреймворкам. Я лишь высказал свое мнение.
        Но чисто из интереса, какие свойства не поддерживает JSX? Как-то я никогда не сталкивался с этой проблемой, вдруг когда пригодится.

        И да, я не говорил, что статья плохая. Хотя стиль изложения довольно сложный.


        1. RubaXa
          03.07.2016 14:27

          Не поддерживается любой кастомный атрибут, только data-*, aria-* и белый список.


          С событиями тоже самое, только белый список.


          Притом еще не очень понятна логика, почему список именно такой, думал они убрали всё что устарело по мнению html5, но это не так.


          1. Akuma
            03.07.2016 14:39

            Хм. Я даже никогда не просматривал подробно этот список поддерживаемых атрибутов. И тем не менее, проблем не возникало.

            Ну ладно, может какие-то частные случаи и правда бывают.


            1. RubaXa
              03.07.2016 14:43
              +1

              Ну, это до первой интеграции со сторонней либой, особенно если необходимо слушать костюмные события, придется использовать ref'ы и руками подписывать/отписываться от событий на mount/unmout.


              1. Akuma
                03.07.2016 15:01

                Да вроде нормально все интегрируется. По крайней мере то, что я использую.

                Но вообще, если используете кастомные события, то ручное отписывание-подписывание это вполне нормально, как мне кажется.


                1. RubaXa
                  03.07.2016 15:20

                  Ну, не имя возможности написать onCustomEvent={...}, спасибо и на этом. Но если требуется список нод, то решение через ref выглядело страшно, но хорошие новости есть, теперь вместо имени, можно и нужно использовать callback.


      1. atc
        03.07.2016 17:02

        Вообще этот пример практически полностью повторяет структуру компонента\модели из Ember.js, в том числе контролем за состоянием, попробуйте, возможно вам понравится.


  1. lega
    03.07.2016 14:16

    Для сравнения, за ~10 мин сделал аналог на Angular Light, получилось вроде компактнее. Т.к. на jsfiddle с сервером не очень, сделал загрузку с github.


    1. RubaXa
      03.07.2016 14:37
      +1

      Представьте, что список юзеров нужен в других местах и там так же надо знать в каком он состоянии, а еще следить за его изменениями. Если с флагом loading я ещё могу представить, как можно выкрутится, то во как следить за изменениями вне контроллера, а это значит не имея scope.$watch, нет.


      1. lega
        03.07.2016 15:00
        +1

        Существует масса способов «синхронизации», Angular Light не «заставляет» использовать какой-то конкретный, он отдает это на откуп программисту.
        Например сервис как общее хранилище, или pubsub/FRP, тот же $watch можно свободно использовать и вне контроллера (опять же это решает разработчик), ChangeDetector — это самостоятельный объект который просто отслеживает изменения, и его можно использовать где угодно.
        Я чаще всего использую сервис + pubsub, т.к. это просто и удобно.


        1. RubaXa
          03.07.2016 15:10
          +1

          Всё верно, но это нужно знать, но мы же в рамках basis говорим, он из коробки говорит как сделать и потом не бояться масштабирования. Я всё это к тому, что эквивалентный код, этому примеру не такой простой, вот и всё.


        1. lahmatiy
          03.07.2016 15:28
          +2

          В статье, видимо для упрощения, все указано в одном модуле. На практике модели данных, наборы и логика их синхронизации описывается отдельно от view, и ничего не знают о view. Так, что любое view может в любой момент «сказать», что ему нужны определенные модель/коллекция/что-то еще. При этом данные могут быть еще не загружены, или загружаться, или быть уже загруженными. Представьте сколько сценариев вам нужно предусмотреть и реализовать, особенно если хотите чтобы не делалось лишних запросов к серверу и все данные были согласованы/актуальны.
          В basis.js с этим несколько проще. view привязывая данные «сообщают» им что есть активный потребитель, и они при необходимости синхронизируются. Можно всегда получить текущее актуальное состояние данных и поменять интерфейс соотвественно. По большей части все разделено и не требует изобретения велосипедов (по крайней мере для частых кейсов точно)


          1. jonic
            04.07.2016 01:30
            +1

            Backbone?


    1. vintage
      06.07.2016 16:11

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


      1. lega
        06.07.2016 23:31

        Вы про то, что если загрузка не пройдет? Это просто пример, не готовое приложение, а главное удовлетворяет условиям (возможностям из списка в статье). Большее число пунктов связано с UI, поэтому я и сделал пример для сравнения UI.
        Да, Angular Light не предлагает инструментов для синхронизации с сервером, тут «свободный полет», и у меня на проектах не возникает с этим проблем.


        1. vintage
          07.07.2016 10:54
          +1

          Я про то, что всё компактно лишь в примерах, а как начинаешь все кейсы учитывать так код и обрастает копипастой.


          1. lega
            07.07.2016 11:19

            Вы используете что-то готовое в качестве модели на клиенте и синхронизации?

            У меня в разных проектах часто разные нюансы, в итоге какая-то универсальная тулза не очень подходит, например в одном проекте используются веб сокеты для данных, в другом — интернет явление временное и модель должна синхронизировать отложено с учетом того что девайс может вообще уснуть и проснутся в любой момент, или приложение может быть заморожено.

            В basis.js есть возможность это законфигурировать? Думаю тоже обрастет доп. кодом, что сильно понизит ценность готовых моделей и синхронизации.
            Это похоже на «универсальный ЯП» который не существует.


            1. vintage
              07.07.2016 13:18

              Не знаю насчёт базиса, у меня свой велосипед :-) фрп, впринципе, позволяет неплохо абстрагироваться от протокола взаимодействия с сервером. Будь он синхронным или асинхронным, затягивающим или толкающим, реального времени или сессионным.


              1. lega
                07.07.2016 13:24

                И я за такой подход.


                1. vintage
                  07.07.2016 15:49

                  Но в ангуляре не такой совсем. По умолчанию, ангуляр просто остановит рендеринг. Чтобы он этого не делал, нужно заворачивать всё в трайкатчи или промисы и вручную прокидывать ошибки. В лайте вроде так же. Или я не прав?


                  1. lega
                    07.07.2016 15:56

                    Вы про какие ошибки?, приведите пример.


                    1. vintage
                      07.07.2016 17:32

                      Ошибка сети, ошибка парсинга ответа, ошибка доступа к полям нулла.


                      1. lega
                        07.07.2016 18:38

                        Ошибка сети, ошибка парсинга ответа
                        всё в трайкатчи или промисы и вручную прокидывать ошибки.

                        Все сводится к тому что вы получаете событие об ошибки либо кетч, и меняете где-то статус/посылаете событие. В итоте нет разницы ваш фрп это или алайт.
                        Тот же парсинг json у вас будет где-то завернут в трайкетч. Далее измененный статус уже рендерится. С фрп то же самое, кетч делает пуш, оно идет по звеньям, меняется интересующий статус и вызывается событие на рендеринг.

                        ошибка доступа к полям нулла

                        Если речь про рендеринг, Ангуляр 1 просто выведет пустую строку. В Ангуляр 2 и алайт это опционально, сейчас там elvis оператор.


                        1. vintage
                          07.07.2016 21:51

                          Вопрос лишь в том, делается ли это автоматически, или руками прописывается везде.


                          А при чём тут экпрешены в шаблонах? :-)


                          1. lega
                            07.07.2016 22:17

                            А при чём тут экпрешены в шаблонах? :-)
                            В ангуляре 1 выражение {{obj.name}} выдает пустую строку если obj == null, в ангуляре 2 оно будет выдавать ошибку, и если в вашем приложении так задумано, что obj может быть null, то нужно писать так: {{obj?.name}}. Т.е. сейчас ошибка не прячется и её сразу видно.
                            Я думал вы это имеете ввиду, Ангуляр — это же про биндинг в первую очередь.


                            1. vintage
                              07.07.2016 23:22

                              Не, я говорил про различные ошибки в коде контроллера. А в шаблоне будет что-то типа {{view.getUserListOnDemand()}}.


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


                              1. lega
                                07.07.2016 23:44

                                должно показывать, что такой-то функционал дал сбой
                                Angular Light для данного примера выдает в консоль:
                                1) текст ошибки
                                2) правильный трейс (можно кликнуть и перейти в файл на строку с ошибкой)
                                3) элемент на котором случилась проблема (можно кликнуть и перейти в DOM на проблемный элемент)
                                4) «привязанный» скоуп с данными
                                и ещё доп. информацию в зависимости от того места где произошла ошибка. Это работает для Chrome (для FF тоже должно).


                                1. vintage
                                  08.07.2016 05:15

                                  Это замечательно, но пользователь-то видит:


                                  {{view.getUserListOnDemand()}}

                                  Кстати, у вас там ошибка выводится 2 раза с разными стектрейсами.


                                  1. lega
                                    08.07.2016 08:35

                                    2 раза с разными стектрейсами

                                    Потому что 2 раза вызывается, главное что трейсы правильные (кстати ключевая часть трейса одна и та же, я даже не обратил внимания)

                                    но пользователь-то видит:

                                    Это должен видеть разработчик при тестировании (либо авто-тестер).
                                    А вы предлагаете прятать ошибку?


                                    1. vintage
                                      08.07.2016 11:41

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


            1. lahmatiy
              07.07.2016 17:54

              В basis.js при работе с данными абстрагируются от способа получения этих данных. По сути вызывается метод, который что-то делает и меняет состояние данных в соответствии с происходящим. На уровне данных (модели, коллекции) мы всегда работаем с механизмом состояний, чтобы понять что с ними происходит. Получение же данных может быть организовано как угодно хоть через XHR, хоть через сокеты, хоть из IndexDB/localStorage/etc или вообще генерацией в web worker'ах. Эта реализация выносится отдельно и может меняться, без необходимости менять описание/логику данных и представления.


              1. lega
                07.07.2016 18:47

                По сути вызывается метод, который что-то делает и меняет состояние данных
                Эта реализация выносится отдельно и может меняться

                Если в данном случае синхронизация все равно делается вручную, тогда непонятно зачем для этого basis.js, если эту синхронизацию можно вызвать напрямую. Видимо эти враперы позволяют состыковать с остальной частью басиса.


  1. impwx
    03.07.2016 16:11

    Немного странно видеть в 2016 году библиотеку, в которой компоненты по умолчанию стилизованы под Windows XP.


    1. lahmatiy
      03.07.2016 18:09
      +2

      Все таки больше под Windows Vista. И странного в этом мало, т.к. фреймворк развивается где-то с 2006го. В каждом проекте свой дизайн и стандартный вид компонент не используется, вот и не дойдут руки его переделать.


  1. jonic
    04.07.2016 01:28

    И чем это кроме шаблонизатора отличается от Backbone.Ribs?


    1. RubaXa
      04.07.2016 09:03

      Выше lahmatiy написал же:


      фреймворк развивается где-то с 2006го

      Backbone — появился в конце 2010, Backbone.Ribs — 2014


      Да и похожего у них очень мало, только факт наличия обертки на данными и коллекциями, да и то, это если очень сильно упростить. Basis наверно лучше сравнивать с KO, как мне кажется, если уж сравнивать, если я не прав, путь Роман меня поправит.


      1. vintage
        04.07.2016 13:00

        Не KO тут ни при чём, в KO автоматический трекинг зависимостей.


        1. RubaXa
          04.07.2016 13:08

          А разве нет? Так же создаем над всем обертки, они между собой сами связываются/подписываются, в КО вроде тоже никакой магии нет, так же всё observable, м?


          1. vintage
            04.07.2016 17:03

            Разница как между "толкать" и "тянуть" :-)


      1. lahmatiy
        04.07.2016 16:45
        +4

        Так или иначе похожесть в каких-то моментах можно найти между многими фреймворками/библиотеками. Но куда важней не конкретная функциональность, а то как это все между собой стыкуется и что в итоге получается.
        Token/Value/Expresion в basis.js близки к Observable/computed в KO — это так. В Ember тоже есть аналоги, как, наверняка, и в других фреймворках. FRP совсем не уникальный паттерн, как и ООП например. То что это реализовано совсем по разному не имеет того значения, как возможности, которые дает конкретное решение. В случае basis.js, например, реактивные значения могут привязываться ко многим ключевым свойствам объектов и если они содержат подходящее значение, то его подстановка происходит автоматически. Думаю это еще будет раскрыто в будущих статьях.


        Что же касается Backbone и подобных фреймворков, то они не стремятся к разделению на логику и представление. Вот пример из readme Backbone.ribs


        var BindingView = Backbone.Ribs.View.extend({
            bindings: {
                'el': {
                    toggle: 'model.isVisible'
                },
                '.bind-text': {
                    text: 'model.title'
                }
            },
        
            el: '<div class="bind">' +
                '<span class="bind-text"></span>' +
            '</div>',
        
            initialize: function () {
                this.model = new Backbone.Ribs.Model({
                    isVisible: true,
                    title: 'Ribs'
                });
        
                this.$el.appendTo('body');
            }
        });

        Что здесь плохо (ключевое):


        • view знает о разметке все, более того определяет как ее менять
        • сильная завязка на структуру разметки (для селекторов) и нужно хитро размечать — например, для вставки текстового значения нужен контейнер
        • разметка (шаблон) сильно завязывается на особенность реализации view (изменение разметки может сломать view)
        • модель назначается один раз и поменять ее без пересоздания view нельзя (или крайне не просто, поправьте меня если не прав)

        Давайте взглянем на тот же пример на basis.js


        var BindingView = Node.subclass({
            binding: {
                'isVisible': 'data:',
                'title': 'data:'
            },
        
            template: resource('./rel/path/to/template.tmpl'),
        
            container: document.body
        });

        В basis.js практически все можно изменить динамически и он максимально нацеливает на разделение логики и представления.
        В чем разница:


        • разметка (шаблон) вынесены из кода, и view совершенно все равно что там (да, совсем — хоть пустая строка) — это так же позволяет реализовывать из коробки продвинутые инструменты вроде live update (обновление разметки компонент без перезагрузки страницы), изоляцию стилей, линтинг и т.д. (немного про инструменты я рассказывал год назад https://www.youtube.com/watch?v=IUtbbN9aevU&list=PLf0s9ihTnfHyGOoQ_7Urte2lBRrJzqRqa)
        • в биндингах описываются какие значения мы хотим чтобы были доступны в шаблоне и как они вычисляются (тут пример простой, и используется сокращенная запись — брать значения как есть из поля data) — на усмотрение шаблона использовать эти значения или нет (если что-то не используется, то это и не вычисляется)
        • компонент абстрагируется от источника данных, он может сам хранить данные или брать из привязанной модели (через механизм делегирования), способ получения данных может быть в сторонке и сколь угодно сложным

        // создаем view с собственными данными
        var view = BindingView({
          data: { title: 'own data' }
        });
        // view.data.title === 'own data'
        
        // обновляем
        view.update({ title: 'updated title' });
        // view.data.title === 'updated title'
        
        // а теперь привяжем модель
        var foo = new basis.data.Object({
          data: { title: 'model data (foo)' }
        });
        view.setDelegate(foo);
        // view.data.title === 'model data (foo)'
        
        // меняем данные у view, но также меняются данные у привязанной в текущей момент модели
        view.update({ title: 'updated (foo)' });
        // view.data.title === 'updated (foo)'
        // foo.data.title === 'updated (foo)'
        
        // привяжем другую модель
        var bar = new DataObject({
          data: { title: 'model data (bar)' }
        });
        view.setDelegate(bar);
        // view.data.title === 'model data (bar)'
        
        // давайте нужную модель будет хранить реактивное значение
        var selectedModel = new basis.Token(foo); // сохраним в начале туда модель foo
        view.setDelegate(selectedModel);
        // view.data.title === 'updated (foo)'
        
        // а теперь поменяем значение реактивного значения на модель bar
        selectedModel.set(bar);
        // view.data.title === 'updated (bar)'
        
        // а теперь хотим чтобы view назначалась вторая по величине (amount) модель в наборе
        var models = new basis.data.Dataset({ // коллекция моделей
          items: [
            new basis.data.Object({ data: { amount: 1, title: 'one' } }),
            new basis.data.Object({ data: { amount: 123, title: 'two' } }),
            new basis.data.Object({ data: { amount: 42, title: 'three' } }),
            new basis.data.Object({ data: { amount: 4, title: 'four' } }),
            new basis.data.Object({ data: { amount: 13, title: 'five' } })
          ]
        });
        var sliceByAmount = new basis.data.dataset.Slice({  // автоматическая коллекция: упорядочивает по rule модели и возвращает заданное "окно" (срез) моделей
          source: models,   // делаем срез из коллекции models
          rule: 'data.amount',
          offset: 1,        // начало среза начинается со второго элемента
          limit: 2,         // размер среза (для примера)
          orderDesc: true   // порядок по убыванию
        });
        // в sliceByAmount модели будут располагаться так (значение amount, `[` и `]` обозначают границы среза)
        // 123 [ 42 13 ] 4 1
        
        view.setDelegate(sliceByAmount.left(0));  // назначаем первую (нулевую) модель на левой границе среза
        // view.data.title === 'three'
        
        // давайте делать срез только по нечетным моделям: для этого в качестве источника среза назначаем коллекцию-фильтр
        sliceByAmount.setSource(new basis.data.dataset.Filter({
          source: models,
          rule: function(model) {
            return model.data.amount % 2 === 1;
          }
        }));
        // теперь в sliceByAmount только нечетные модели:
        // 123 [ 13 1 ]
        // а во view актуальное значение:
        // view.data.title === 'five'
        
        // добавим моделей в models?
        models.add([
            new basis.data.Object({ data: { amount: 77, title: 'six' } }),
            new basis.data.Object({ data: { amount: 100, title: 'seven' } }),
            new basis.data.Object({ data: { amount: 111, title: 'eight' } })
        ]);
        
        // фильтр отфильтрует ненужное и в sliceByAmount будут только нечетные модели в нужном порядке:
        // 123 [ 111 77 ] 13 1
        // во view по прежнему актуальное значение
        // view.data.title === 'eight'
        
        // хотим чтобы бралось третье по величине значение? один из способов поменять смещение в срезе:
        sliceByAmount.setOffset(2);
        // view.data.title === 'six'
        
        // и т.д. и т.п.

        Сорри за полотно кода. Вероятно не зная фреймворка в этом может сложно разобраться, но это быстро проходит.
        Ключевая же идея здесь в том, что в примере несколько раз измен способ получения данных для view. При этом реализация самого view осталась нетронутой. И вся работа с данными может быть вынесена отдельно и меняться в дальнейшем. О синхронизации данных заботиться фреймворк. А не так давно мы научились визуализировать потоки данных (вот тут несколько примеров как это выглядит, и Настя Горячева рассказывала об этом на недавних конференциях).
        Ровно так же мы можем свободно менять разметку (шаблон) компонента, совсем не думая о его реализации. И внутри все сделано достаточно оптимально, и в большинстве случаев можно не беспокоиться о производительности.


        Как я писал в начале, в разных фреймворках и библиотеках есть те или иные похожие решения. Вопрос в том, насколько хорошо они позволяют декомпозировать, абстрагироваться и решать действительно сложные задачи. Basis.js не панацея и никто не утверждает что он идеален. Но есть большое количество задач, с которыми он успешно справляется. Так как разрабатывается достаточно давно и нацелен на большие SPA, которые создаются и поддерживаются годами.


        Но имхо здесь нужно не столько сравнивать, сколько присмотреться к тому или иному решению. Я всегда призываю смотреть именно на идеи, больше чем на конкретные реализации.


  1. alk0v
    04.07.2016 14:43

    А будет пример с редактируемой таблицей, чтобы в столбцах были связанные комбо-боксы? (значения второго зависят от того, что выбранно в первом)


    1. smelukov
      04.07.2016 14:43

      сделаем ;)


      1. vintage
        04.07.2016 17:01

        А эксельчик? ;-)