Всем доброго времени суток.
Продолжаю интереснейший цикл статей про создание продвинутых Single Page Application на basis.js.
В прошлый раз мы научились работать с коллекциями и реализовали полноценный интерактивный список.
В этот раз мы начнем создавать полноценный клиент для ВКонтакте.
А именно: реализуем авторизацию, загрузку новостей, друзей и музыки.

Меню и навигация


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

При клике по пунктам меню (вкладкам), происходит навигация на разные url и выделение активного пункта меню.
Давайте посмотрим на код главного файла нашего будущего приложения:
let Node = require('basis.ui').Node;
let Header = require('app.ui.header.component');
let Menu = require('app.ui.menu.component');

require('basis.app').create({
  title: 'VK Client by Basis.JS',
  element: new Node({
    template: resource('./template.tmpl'),
    binding: {
      header: 'satellite:',
      menu: 'satellite:'
    },
    satellite: {
      header: Header,
      menu: Menu
    }
  })
});

Подключаем необходимые модули: Node, а так же компоненты заголовка и меню (которые рассмотрим ниже).
Далее создается приложение, при помощи метода basis.app.create(). Можно, конечно, обойтись без него и делать так, как мы делали раньше — создавать новый Node и помещать его в какой-нибудь элемент на странице.
Но в basis.js имеется хелпер basis.app, который инкапсулирует некоторую логику, связанную с заголовком страницы и размещением корневого компонента приложения на странице.
Так же, посмотрим на шаблон нашего приложения:
<div class="container">
  <!--{header}-->
  <!--{menu}-->
  <hr/>
</div>

Заголовок и меню являются сателлитами корневого компонента нашего приложения.
На данный момент, компонент заголовка очень простой:
let Node = require('basis.ui').Node;

module.exports = Node.subclass({
  template: resource('./template.tmpl') // <h1>Добро пожаловать!</h1>
});

Его задача — выводить приветствие. Позже мы его улучшим.
А вот компонент меню представляет для нас особый интерес:
let Node = require('basis.ui').Node;
let Value = require('basis.data').Value;
let router = require('basis.router');
let currentPage = Value.from(router.route(':page').param('page'));

module.exports = Node.subclass({
  template: resource('./template.tmpl'), // <div class="btn-group btn-group-lg"/>
  childClass: {
    template: resource('./item.tmpl'),
    selected: currentPage.compute((node, page) => node.url == page),
    binding: {
      title: 'title'
    },
    action: {
      click() {
        router.navigate(this.url);
      }
    }
  },
  childNodes: [
    {title: 'Новости', url: 'news'},
    {title: 'Друзья', url: 'friends'},
    {title: 'Музыка', url: 'audio'}
  ]
});

Давайте сразу обратим внимание на содержимое переменной currentPage.
Здесь всегда будет храниться актуальный маршрут, изменения которого мы можем отслеживать.
Это значение мы используем в свойстве selected пунктов меню.
То есть активность конкретного пункта меню зависит от текущего маршрута.
Если url текущего пункта меню совпадает с текущим маршрутом, то у этого пункта меню свойство selected = true.
Таким образом, в один момент времени будет выбран только один пункт меню.
При клике на конкретный пункт, происходит навигацию к указанному url.
Более подробно о роутере, встроенном в basis.js можно почитать в соответствующем разделе документации.

Теперь посмотрим на шаблон пункта меню:
<b:define name="active" from="selected" type="bool"/>

<button type="button" class="btn btn-default {active}" event-click="click">
  {title}
</button>

Каждый пункт меню — это кнопка. Если selected пункта равен true, то добавляем к кнопке класс active, в ином случае — убираем.

Вот и всё. Меню с навигацией — готово.
Теперь, при нажатии на пункты меню, будет происходить переход по соответствующему url.
Осталась небольшая мелочь — маршрут по умолчанию.
Если просто открыть наше приложение, без указания маршрута, то ни один пункт меню выбран не будет.
Давайте исправим это таким образом, чтобы маршрутом по умолчанию были Новости.
Модифицируем основной файл нашего приложения:
// ...
let router = require('basis.router');
let defaultRoute = 'news';

require('basis.app').create({
  title: 'VK Client by Basis.JS',
  element: new Node({
    // ...
  })
}).ready(() => {
  router.route('*page').param('page').as(page => page || router.navigate(defaultRoute, true));
});

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

Авторизация


Теперь задействуем ВКонтакте API и реализуем с его помощью авторизацию.
Посмотрите на обертку над VK API (далее просто API). Мы не будем рассматривать ее полностью, а посмотрим только на ключевые моменты.
Обратите внимание, что сам API является наследником от basis.data.Value.
Это значит, что у нее, как и у любого источника данных, есть состояния:
  • UNDEFINED когда пользователь не авторизован
  • PROCESSING во время авторизации
  • READY после успешной авторизации
  • ERROR в случае ошибки

Посмотрим на то, как реализована смена состояний модели. Для этого обратимся к методам login() и logout():
login() {
  this.setState(STATE.PROCESSING);
  this.isLoggedIn().then(
    () => this.setState(STATE.READY),
    () => {
      global.VK.Auth.login(response => {
        this.setState(response.session ? STATE.READY : STATE.UNDEFINED);
      }, config.perms);
    }
  );
},
logout() {
  global.VK.Auth.logout();
  this.setState(STATE.UNDEFINED);
}

Вызывая login(), API будет переведен в состояние PROCESSING.
Далее идет проверка — если пользователь уже авторизован, то сразу переводим API в состояние READY. Если нет, то авторизуемся при помощи метода VK.Auth.login() из VK API. Процесс авторизации через VK API сводится к тому, что вам показывается окно с предложением ввести логин и пароль.

Когда окно будет закрыто (авторизация прошла успешно или была отменена), будет вызван переданный callback, в котором будет установлено конечное состояние нашей модели: READY, в случае успешной авторизации и UNDEFINED, в случае отмены авторизации.
Вызывая logout(), уничтожаем сессию методом VK.Auth.logout() и переводим API в состояние UNDEFINED.
Теперь посмотрим на другой важный метод — callApi():
callApi(method, params = {}) {
  return this.isLoggedIn()
    .catch(e => Promise.reject(new Error('Ошибка авторизации!')))
    .then(
      () => {
        return new Promise((resolve, reject) => {
          basis.object.complete(params, {v: config.version});

          global.VK.api(method, params, response => {
            if (response.error) {
              reject(new Error(response.error.error_msg));
            } else {
              resolve(response.response);
            }
          });
        });
      },
      e => {
        this.setState(STATE.ERROR, e.message);
        throw e;
      }
    );
}

Суть данного метода — отправить запрос через VK API. Перед выполнением каждого запрос проверяем наличие авторизации. Если авторизации нет (например мы открыли наше приложение в двух вкладках браузера и в одной из них нажали выйти), то выбрасываем ошибку и переводим API в состояние ERROR. Если с авторизацией всё хорошо, то выполняем запрос. Если сервер, в ответ на запрос, сообщает нам об ошибке — выбрасываем ошибку и переводим API в состояние ERROR. В ином случае — возвращаем результат.

За счет этого, мы можем абстрагироваться от нюансов работы с VK API и оперировать лишь состояниями модели:
let STATE = require('basis.data').STATE;
let Value = require('basis.data').Value;
let Node = require('basis.ui').Node;
let router = require('basis.router');
let Header = require('app.ui.header.component');
let Menu = require('app.ui.menu.component');

let vkApi = require('app.vkApi');
let apiState = Value.state(vkApi);
let defaultRoute = 'news';

require('basis.app').create({
  title: 'VK Client by Basis.JS',
  element: new Node({
    // компонент приложения активен, пока пользователь авторизован
    active: apiState.as(state => state == STATE.READY),
    // компонент приложения заблокирован в процессе авторизации
    disabled: apiState.as(state => state == STATE.PROCESSING),
    template: resource('./template.tmpl'),
    binding: {
      header: 'satellite:',
      menu: 'satellite:',
      // будет содержать текст ошибки, в случае ее возникновения
      error: apiState.as(state => state == STATE.ERROR && state.data)
    },
    satellite: {
      header: Header,
      menu: Menu
    },
    action: {
      // обработка кнопки "авторизоваться"
      login() {
        vkApi.login();
      }
    }
  })
}).ready(() => {
  router.route('*page').param('page').as(page => page || router.navigate(defaultRoute, true));
  // пытаемся авторизоваться сразу после инициализации приложения
  vkApi.login();
});

Теперь применим эти свойства в шаблоне:
<div class="container">
  <div b:show="{active}">
    <!--{header}-->
    <!--{menu}-->
    <hr/>
  </div>
  <div class="jumbotron text-center" b:hide="{active}">
    <h1>
      VK Client
      <small> powered by basis.js</small>
    </h1>
    <div class="alert alert-danger" b:show="{error}">
      {error}
    </div>
    <button class="btn btn-primary btn-lg" event-click="login" disabled="{disabled}">Авторизация</button>
  </div>
</div>

Показываем экран приветствия пока пользователь не авторизован, в ином случае — показываем меню и заголовок.
Кнопка Авторизация будет заблокирована в процессе авторизации.
Так же, добавим кнопку выйти в главное меню:
<div>
  <div{childNodesElement} class="btn-group btn-group-lg"/>
  <button class="btn btn-primary btn-lg pull-right" event-click="logout">выйти</button>
</div>

И, в компоненте меню, обработаем клик по этой кнопке:
let vkApi = require('app.vkApi');
// ...

module.exports = Node.subclass({
  // ...
  action: {
    logout() {
      vkApi.logout();
    }
  }
  // ...
});


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

Страницы


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

Для начала, создадим общую страницу, от которой будем наследовать все остальные:
let Value = require('basis.data').Value;
let Expression = require('basis.data.value').Expression;
let STATE = require('basis.data').STATE;
let Node = require('basis.ui').Node;

module.exports = Node.subclass({
  active: basis.PROXY,
  binding: {
    loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING),
    error: Value.query('childNodesState').as(state => state == STATE.ERROR && state.data),
    empty: node => new Expression(
      Value.query(node, 'childNodesState'),
      Value.query(node, 'childNodes.length'),
      (state, itemCount) => !itemCount && state == STATE.READY
    )
  },
  handler: {
    activeChanged() {
      if (this.active) {
        this.dataSource.deprecate();
      }
    }
  }
});


<div>
  <div class="alert alert-info" b:show="{loading}">загружается...</div>
  <div class="alert alert-warning" b:show="{empty}">список пуст</div>
  <div class="alert alert-danger" b:show="{error}">{error}</div>
  <div{page} b:hide="{loading}"/>
</div>

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

Нечто похожее мы уже делали в прошлый раз.
Чтобы не дублировать код в компонентах всех трех страниц, выносим его в отдельный файл.
Давайте подробнее рассмотрим что там происходит:
Наша абстрактная страница — это всего лишь Node с определенными биндингами и еще парой деталей.
Я не буду сейчас останавливаться на этих биндингах, так как они подробно разбирались в прошлый раз.
Сейчас нас больше инеересует другое.
Что такое active: basis.PROXY?
В прошлый раз мы выяснили, что набор данных начинает синхронизацию только когда его состояние UNDEFINED или DEPRECATED и у него есть активный потребитель. Выполнение этих двух условий обязательно для того, чтобы запустить процесс синхронизации набора. Сейчас нас больше интересует часть про "когда есть активный потребитель".
Потребитель — это сущность (наследник basis.data.AbstractData), которой нужны данные (и при том актуальные), представленные в другом объекте данных.
Активный потребитель — это потребитель со свойством active = true.

По умолчанию, когда для Node назначается dataSource, Node автоматически становится потребителем данного набора.
Отлично, потребитель есть. Но активный ли он?
Опять же, по умолчанию, Node не является активным потребитетем (свойство active = false).
"А давайте просто добавим active: true в описание Node и проблема будет решена" — можете предложить вы.
Не всё так однозначно. Мы ведь делаем умное приложение? А значит набор должен не просто один раз синхронизироваться при запуске приложения, а еще и обновлять свои данные по мере необходимости.
У нас есть три страницы и три набора под каждую из них (новости, друзья и аудиозаписи). Будем запускать синхронизацию набора только тогда, когда переходим на вкладку, которая нуждается в этом наборе. Таким образом, мы не только реализуем механизм актуализации данных, но еще и добавим "ленивую" синхронизацию данных. То есть синхронизацию только при необходимости.
Исходя из этого, при переходе на какую-либо вкладку, мы должны переводить состояние соответствующего набора в DEPRECATED.
Но как узнать, что мы переключились на какую-либо вкладку?
Скорее всего вы уже начали думать, что мы всё дальше отдаляемся от первоначального вопроса.
Но это не так. Еще чуть-чуть и вы увидите, как все сюжетные линии сольются воедино, прояснив общую картину.
Итак, как узнать, что мы переключились на какую-либо вкладку?
Как и в случае с dataSource, Node автоматически становится потребителем всех своих сателлитов, а так же их владельцем. Мы сделаем так, что при переключении между вкладками, соответствующая страница будет становиться сателлитом корневого Node нашего приложения.
Значит можно заставить страницу среагировать в тот момент, когда она станет сателлитом и в этот момент перевести состояние своего набора в DEPRECATED:
// ...

module.exports = Node.subclass({
  // ...
  handler: {
    ownerChanged() {
      if (this.owner) {
        this.dataSource.deprecate();
      }
    }
  }
});

Отлично! В тот момент, когда страница станет сателлитом корневого компонента нашего приложения, набор данных перейдет в состояние DEPRECATED.

Но давайте еще раз проговорим: "Набор данных начинает синхронизацию только когда его состояние UNDEFINED или DEPRECATED и у него есть активный потребитель."
С переключением состояния набора и наличием потребителя мы разобрались. Но как быть с активностью? Если просто добавить к странице active: true, то она будет всегда активна и ее источник данных попытается синхронизировать данные сразу при создании, вне зависимости от того, нужны нам сейчас эти данные или нет.
Нам это не совсем подходит, ведь есть случаи, когда синхронизация просто невозможна. Например, когда мы еще не прошли процедуру авторизации или отключен интернет.
Чтобы не заоморачиваться обработкой этих кейсов в самой странице, добавим ей свойство active: basis.PROXY, которое переведет Node в особый режим, в котором Node будет активен только тогда, когда у него самого есть активный потребитель.
Зная это, нет необходимости отслеживать ownerChanged, а лучше подписаться на activeChanged. Таким образом будем застравлять набор синхронизировать данные только в момент появляения активного потребителя.
Взгляните еще раз на итоговый код компонента страницы:
let Value = require('basis.data').Value;
let Expression = require('basis.data.value').Expression;
let STATE = require('basis.data').STATE;
let Node = require('basis.ui').Node;

module.exports = Node.subclass({
  active: basis.PROXY,
  binding: {
    loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING),
    error: Value.query('childNodesState').as(state => state == STATE.ERROR && state.data),
    empty: node => new Expression(
      Value.query(node, 'childNodesState'),
      Value.query(node, 'childNodes.length'),
      (state, itemCount) => !itemCount && state == STATE.READY
    )
  },
  handler: {
    activeChanged() {
      if (this.active) {
        this.dataSource.deprecate();
      }
    }
  }
});

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

Теперь картина должна проясниться:
  • есть три страницы (новости, друзья, аудиозаписи)
  • у каждой старницы свой собственный набор данных, который умеет получать данные при помощи VK API
  • при переключении между вкладками, сателлитом основного компонента приложения будет становиться соответствующая вкладке страница
  • страница активна только когда у нее есть активный потребитель


Теперь перейдем к созданию наследников рассмотренного компонента страницы.
Начнем с новостей:
let Page = require('../Page');

module.exports = new Page({
  template: resource('./list.tmpl'),
  childClass: {
    template: resource('./item.tmpl'),
    binding: {
      text: 'data:',
      date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S'))
    }
  }
});

Шаблон оставляю на ваше усмотрение.
Код остальных двух страниц аналогичен.
У страницы все еще нет источника данных. К этому вопросу мы еще вернемся, а пока посмотрим на то, как отображать страницу, которая соответствует открытой вкладке.
Модифицируем главный файл нашего приложения:
// ...
let pageByName = {
  news: resource('./ui/pages/news/component.js'),
  friends: resource('./ui/pages/friends/component.js'),
  audio: resource('./ui/pages/audio/component.js')
};

require('basis.app').create({
  title: 'VK Client by Basis.JS',
  element: new Node({
    // ...
    binding: {
      // ...
      page: 'satellite:'
    },
    satellite: {
      // ...
      page: router.route(':page').param('page').as(page => pageByName[page])
    }
  })
})
// ...

Сателлитом с именем page будет являться компонент, который соответствует текущему маршруту на основании карты в переменной pageByName. Теперь нужно добавить использование этого сателлита в шаблон:
<div class="container">
  <div b:show="{active}">
    <!--{header}-->
    <!--{menu}-->
    <hr/>
    <!--{page}-->
  </div>
  ...
</div>

Теперь, если бы у страниц был источник данных, то наше приложение начало бы работать.

Источник данных


Выше была показана обертка над VK API. Помимо прочего, там есть методы для получения списка новостей, друзей и аудиозаписей. В качестве источника данных, будем использовать basis.entity — типизированные сущности.
Опишем тип для новостей:
let STATE = require('basis.data').STATE;
let entity = require('basis.entity');
let vkApi = require('app.vkApi');

let News = entity.createType('News', {
  text: String,
  date: Date
});

News.extendReader(data => data.date *= 1000);
News.all.setSyncAction(() => vkApi.news().then(News.all.set));

module.exports = News;

Каждая новость состоит из двух полей — текста и даты.
Заметьте, что мы расширяем reader. Данная возможность используется в тех случаях, когда необходимо модифицировать данные перед тем, как они станут экземпляром типа.
Так же, у каждого типа есть свойство all, которое является набором всех созданных объектов данного типа.
Где бы мы ни создали экземпляр типа News, он будет помещен в набор News.all.
Для данного набора мы определяем syncAction, то есть метод, который будет вызываться в случае необходимости синхронизации.
Всё что нам нужно сделать — получить данные из ВКонтакте и передать их методу News.all.set(), который заменит существующие экземпляры типа News на новые.
Заметьте, что нет необходимости явно указывать контекст метода таким образом: News.all.set.bind(News.all).
Данный метод уже имеет привязку к контексту News.all для удобства использования.
Так же заметьте, что, если метод, указанный в syncAction возвращает промис, то состояние набора данных будет определяться автоматически, в зависимости от состояния промиса.

Теперь News.all может быть передан в качестве источника данных для страницы новостей. Соответственно, в момент активации страницы, состояние News.all будет переведено в DEPRECATED и начнется процесс синхронизации, описанный в syncAction набора

News.all.
Подобным образом опишем оставшиеся два типа:
Friends entity

let entity = require('basis.entity');
let vkApi = require('app.vkApi');

let Friends = entity.createType('Friends', {
  photo: String,
  first_name: String,
  last_name: String
});

Friends.extendReader(data => data.photo = data.photo_100);
Friends.all.setSyncAction(() => vkApi.friends().then(Friends.all.set));

module.exports = Friends;

Audio entity

let entity = require('basis.entity');
let vkApi = require('app.vkApi');

let Audio = entity.createType('Audio', {
  artist: String,
  title: String,
  duration: Date
});

Audio.extendReader(data => data.duration *= 1000);
Audio.all.setSyncAction(() => vkApi.audio().then(Audio.all.set));

module.exports = Audio;

Теперь укажем News.all в качестве источника данных для страницы:
let Value = require('basis.data').Value;
let Page = require('../Page');
let News = require('app.type.news');
let format = require('basis.date').format;
let dataSource = News.all;

module.exports = new Page({
  template: resource('./list.tmpl'),
  dataSource: dataSource,
  childClass: {
    template: resource('./item.tmpl'),
    binding: {
      text: 'data:',
      date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S'))
    }
  }
});

Аналогичным образом, укажем соответствующие наборы другим страницам.

Friends page

let Page = require('../Page');
let Friends = require('app.type.friends');
let dataSource = Friends.all;

module.exports = new Page({
  template: resource('./list.tmpl'),
  dataSource: dataSource,
  childClass: {
    template: resource('./item.tmpl'),
    binding: {
      photo: 'data:',
      first_name: 'data:',
      last_name: 'data:'
    }
  }
});

Audio page

let Value = require('basis.data').Value;
let Page = require('../Page');
let Audio = require('app.type.audio');
let format = require('basis.date').format;
let dataSource = Audio.all;

module.exports = new Page({
  template: resource('./list.tmpl'),
  dataSource: dataSource,
  childClass: {
    template: resource('./item.tmpl'),
    binding: {
      artist: 'data:',
      title: 'data:',
      duration: Value.query('data.duration').as(format('%I:%S'))
    }
  }
});

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

Всё готово, но давайте добавим еще немного улучшений.
Добавим страницу 404. Для этого, модифицируем наш главный файл:
// ...
let pageByName = {
  news: resource('./ui/pages/news/component.js'),
  friends: resource('./ui/pages/friends/component.js'),
  audio: resource('./ui/pages/audio/component.js'),
  notFound: resource('./ui/pages/404/component.js')
};

require('basis.app').create({
  title: 'VK Client by Basis.JS',
  element: new Node({
    satellite: {
      header: Header,
      menu: Menu,
      page: router.route(':page').param('page').as(page => pageByName[page] || pageByName.notFound)
    }
    // ...
  })
})
// ...

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

Кстати, вы заметили, что компоненты подключаются через resource а не через require?
resource позволяет реализовать ленивую инициализацию компонентов.
То есть компонент будет проинициализирован не сразу, а только в тот момент, когда он понадобится в первый раз.
Подробнее о ресурсах можно почитать в соответствующем разделе документации.

И еще один момент. Дело в том, что на вашей стене ВКонтакте могут попадаться не только текстовые новости, но еще и видео/фото без текста. Обработкой этих кейсов мы займемся в другой раз, а пока давайте просто отфильтруем новости таким образом, чтобы отображать только те новости, у которых есть текст. Для этого модифицируем компонент новостей:
let Page = require('../Page');
let Value = require('basis.data').Value;
let News = require('app.type.news');
let format = require('basis.date').format;
let Filter = require('basis.data.dataset').Filter;
let textOnlyNews = new Filter({
  source: News.all,
  state: Value.query('source.state'),
  rule: 'data.text',
  deprecate() {
    this.source.deprecate();
  }
});

module.exports = new Page({
  template: resource('./list.tmpl'),
  dataSource: textOnlyNews,
  childClass: {
    template: resource('./item.tmpl'),
    binding: {
      text: 'data:',
      date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S'))
    }
  }
});

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

И последнее… оживим компонент заголовка:
let Node = require('basis.ui').Node;
let STATE = require('basis.data').STATE;
let DataObject = require('basis.data').Object;
let vkApi = require('app.vkApi');

let dataSource = new DataObject({
  data: {
    firstName: '',
    lastName: ''
  },
  syncAction() {
    return vkApi.me().then(me => {
      this.update({ firstName: me.first_name, lastName: me.last_name });
    });
  }
});

module.exports = Node.subclass({
  active: basis.PROXY,
  delegate: dataSource,
  template: '<h1>Добро пожаловать {firstName} {lastName}!</h1>',
  binding: {
    firstName: 'data:',
    lastName: 'data:'
  }
});

Таким образом, компонент заголовка научился получать и отображать имя и фамилию владельца аккаунта.

Заключение, но не конец


Итак, сегодня мы создали полноценный клиент для ВКонтакте на basis.js. Приложение умеет авторизоваться и синхронизировать данные с сервером.
Обратите внимание на то, что, как и в прошлый раз, мы идем по пути FRP и основной упор делаем на работу с данными. Другими словами: мы выстраиваем поток данных таким образом, чтобы приложение выполняло свои задачи. При этом, специфика basis.js такова, что клиентский код получается довольно линейным из-за отсутствия циклов и большого количества ветвлений. По крайней мере обозначенные задачи можно решить без них.
В следующих статьях мы будем улучшать наш клиент и наращивать его функционал.
Спасибо за интерес к basis.js!

Огромная благодарность lahmatiy за бесценные советы ;)

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

Поделиться с друзьями
-->

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


  1. vintage
    17.07.2016 16:40
    +2

    Если маршрут не указан, то перебрасываем пользователя на маршрут по умолчанию.

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


    Отлично! В тот момент, когда страница станет сателлитом корневого компонента нашего приложения, е набор данных перейдет в состояние DEPRECATED.

    Будет ли при это показывается надпись "Идёт загрузка данных" на весь экран или же будут показаны данные из кэша, а обновление будет происходить в фоне?


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

    Однако мы имеет лапшу из промисов.


    <b:define name="active" from="selected" type="bool"/>

    Адский костыль. Без него совсем никак? В целом, я смотрю, фреймворк требует изучения 100500 абстракций. Так Ангуляр не победить. :-)


    1. lahmatiy
      17.07.2016 17:30
      +2

      Правильно ли я понял, что в истории появится дополнительная запись из-за чего кнопка "назад" перестанет работать

      Да, правильно – в текущей версии кода все так и будет. Нужно добавить true вторым параметром router.navigate(), тогда переход будет с заменой, то есть router.navigate(defaultRoute, true). Думаю Сергей исправит.


      Будет ли при это показывается надпись "Идёт загрузка данных" на весь экран или же будут показаны данные из кэша, а обновление будет происходить в фоне?

      Судя по коду, пока грузятся данные, контент страницы не показывается (см. <div{page} b:hide="{loading}"/> в шаблоне). Можно убрать атрибут b:hide из шаблона и будут показаны данные из кеша (с фоновой синхронизацией). В целом сделать любой альтернативный вариант не сложно.


      Однако мы имеет лапшу из промисов.

      Какую лапшу вы имеете ввиду? Тут вроде промисы приходят только из API ВКонтакте, и дальше адаптера и модели не выходят.


      Адский костыль. Без него совсем никак?

      Это не костыль. Тут выполняются две функции:


      1. по сути переименовывается значение, так как значение приходит в шаблон с именем selected, а в разметку нужно вставить класс active
      2. фиксируется тип значения – так шаблонизатор и инструменты будут знать, что selected это гарантированно булево значение

      А выносится отдельно, чтобы было наглядней и не зашумлять основную разметку, да к тому же описанное значение может использоваться в нескольких местах.
      Без этого в большинстве случаев можно (если не нужно переименование, но его можно сделать и в компоненте), но не нужно. Так как позволяет сделать разметку более предсказуемой и найти в ней ошибки – опечатки, мертвый код (например, https://youtu.be/IUtbbN9aevU?t=27m15s), безопасно переименовывать классы etc.


      1. smelukov
        17.07.2016 17:52
        +1

        > Думаю Сергей исправит.
        Спасибо! Исправил.


      1. smelukov
        17.07.2016 17:59
        +1

        > Можно убрать атрибут b:hide из шаблона и будут показаны данные из кеша (с фоновой синхронизацией).
        Да. Я добавил b:hide для того, чтобы во время показа надписи «загрузка», основной список не сползал вниз и не прыгал в случае быстрой загрузки. Но это уже вопрос UX. Можно убрать b:hide и прикрутить какой-нибудь Line Progress Bar, тогда визуально всё будет гладко


      1. justboris
        17.07.2016 20:41

        Насколько я понимаю, заранее свойство active назвать было нельзя, поскольку свойство с таким именем используется для в Node для других целей, поэтому пришлось назвать selected. А в bootstrap класс активной кнопки, называется active, поэтому нужно переименовать перед присваиванием.

        Причины такого решения понятны, но все равно какой-то костыль получается.


      1. vintage
        18.07.2016 11:54
        +2

        по сути переименовывается значение, так как значение приходит в шаблон с именем selected, а в разметку нужно вставить класс active

        По сути надо выводить или не выводить определённую строку в зависимости от флага. А тут происходит создание переменной-алиаса для флага и вывод её имени, вместо значения. При этом получается, что поведение фигурных скобок отличается в этих двух случаях:


        <button type="button" class="btn btn-default {active}" event-click="click">
          {title}
        </button>

        Неконсистентность API — это то, что убило большинство фреймворков.


        фиксируется тип значения – так шаблонизатор и инструменты будут знать, что selected это гарантированно булево значение

        Весь остальной код даже не на тайпскрипте, но вот именно в шаблонах типы очень важны. :-D А почему тип для title не объявили?


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

        Ну да, что-нибудь типа { selected ?: "active" } — очень ненаглядно и шумит, а вот <b:define name="active" from="selected" type="bool"/> — сразу понятно, что есть что, да.


        1. lahmatiy
          18.07.2016 23:59

          Чтобы не повторяться и дать более полную картину – описание работы <b:define>


  1. lahmatiy
    17.07.2016 20:51
    +2

    свойство active назвать было нельзя, поскольку свойство с таким именем используется для в Node для других целей

    Можно. Свойства view и биндинги это разные вещи. Стандартного биндинга active нет и его можно задать (и даже если бы был – ничто не мешает поменять его для конкретной ноды или класса). С другой стороны, получается view подстраивается под шаблон – чего мы не хотим. Потому в шаблон передается признак, а что дальше с ним будет для view не важно. Сегодня используется bootstrap и класс нужен active. Завтра другой фреймворк и там будут другие классы, или свои стили напишем, со своими именами – нужно будет поменять только шаблон.
    И вы опять все сводите к тому, чтобы выбрать имя для класса – это не единственное. Даже если будет свойство active пробрасываться в шаблон, все равно крайне явно задать тип: <b:define name="active" type="bool"/>


    1. justboris
      18.07.2016 09:08

      Странно то, что подобный define встречается только один раз. Почему в остальных шаблонах он не используется?


      1. lahmatiy
        18.07.2016 09:31

        Это нужно только для биндингов в атрибуте class (чтобы понимать какие классы образуются в разметке). Видимо в данном примере всего один такой биндинг.


        1. justboris
          18.07.2016 09:46

          Теперь понятно.
          Спасибо за ответ!


  1. rpsv
    18.07.2016 07:17
    +1

    А в чем разница, а главное профиты basis.js в сравнении с angular?


    1. smelukov
      18.07.2016 11:41
      +2

      Если говорить про разницу, то она несомненно большая (речь не про лучше/хуже, а про различия).
      Вот некоторые моменты:
      Basis.js имеет более декларативный подход в описании компонентов — налаживание связей между частями приложения и организация потока данных.
      При описании шаблона, разработчик действительно описывает только шаблон, без выражений и условных/циклических операторов, т.к. задачи связи представления с данными выполняют отдельный модули, о чем было рассказано в предыдущей статье.
      В basis.js нет digest-цикла. Синхронизация данных устроена при помощи токенов, которые были рассмотрены в первой части.
      Basis.js «из коробки» поддерживает инструментирование кода, но это тема для отдельной статьи.

      Что из этого считать профитом — решайте для себя сами. Я бы рекомендовал попробовать оба фреймворка в разных задачах и сделать собственные выводы.


  1. babylon
    19.07.2016 15:42
    -1

    Что не понравилось. Повторение грабель Ангуляра. Биндинг вместо рутирования. Проектирование сверху вниз. Шаблоны не в JSON формате. К чему это приводит понятно. Я не знаю, что такое токены. Существующих понятий не хватает.Ага?
    Что понравилось. Авторы очень технично кодят, но очень нетехнично проектируют.


    1. vintage
      19.07.2016 16:26
      +1

      Биндинг вместо рутирования.

      И чем рутирование лучше биндинга?


      Проектирование сверху вниз.

      А в этом что плохого?


      Шаблоны не в JSON формате.

      Я видимо отстал от жизни. Сейчас у верстальщиков можно шаблоны в JSON фигачить?


      К чему это приводит понятно.

      Не очень понятно. Расскажете?


  1. babylon
    19.07.2016 17:02
    -1

    Биндинг это контейнер в шаблоне. Вместо шаблона в контейнере. Верстальщики пытаются фигачить. Но получается пока не очень. https://nodejs.org/dist/latest-v4.x/docs/api/ жмакаем as View as JSON. Не знаю кем они себя считают:)


    1. RubaXa
      19.07.2016 18:18
      +1

      Смешно, думал вы нам про какой-нибудь BEMJSON задвинете или ещё какую-нибудь эзотерику, а вы...


  1. babylon
    19.07.2016 18:46
    -2

    BEMJSON неактуально для меня. И это не изотерика. Есть хабралишенцы, которым нравится данная технология. Мне ||