понедельник, 30 сентября 2013 г.

Backbone для самых маленьких

Добрый день, друзья! Недавно я побывал на конференции dotnetconf. Было много интересного, в том числе доклады по Single Page Application (SPA). Сначала Константин Зотов рассказал нам о backbone на примере простого приложения todo. После, Евгений Абросимов поведал нам о том, как эффективно строить SPA, какие есть подводные камни, и за что можно уволить фронтэнд разработчика Улыбка. Вообще, хоть я и не фронтенд девелопер, у меня уже был небольшой опыт работы с библиотекой backbone и мысль о ней написать в блоге. Но эти два парня вдохновили меня таки это сделать.

Итак, далее речь пойдет о основах библиотеки, и также сделаем небольшой пример. Если Вы хотите ознакомиться с данной библиотекой, или есть желание научиться писать SPA, то эта статья будет Вам полезна. Поехали!

Начнем с описания приложения, которое будем создавать. Я хочу сделать некое подобие CMS (даже громко сказано), то есть заложить возможность создавать HTML страницы, редактировать их содержимое, просматривать (используя хеши), удалять страницы, а также, чтобы вся модель могла сохраняться в локальном хранилище (localstorage). Для начала, мы рассмотрим немного теории, а затем, понемногу перейдем к практике.

Если Вы, также как и я, использовали javascript в основном только для того, чтобы работать с какими то событиями или манипулировать DOM (что, в принципе, достаточно для самых простейших сценариев), то Вы должны понять, почему я начну издалека. Первым делом я расскажу о классах в javascript.

Классы в Javascript

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

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

   1:  var SomeClass = function(){
   2:      this.id = 0;
   3:      this.name = 'name of instance';
   4:  };
   5:   
   6:  var instance  = new SomeClass();
   7:  console.log(instance);

Здесь функция SomeClass является функцией – конструктором. Когда мы вызываем её с ключевым словом new, то контекст функции (то есть переменная this) становится пустым объектом, свойства которого мы заполняем в нашей функции и этот объект является результатом вызова конструктора, потому сохраняется в переменную instance. При выполнении этого кода, мы увидим следующее:

image

Выглядит просто, верно? Но у нас не всегда есть возможность вносить какие либо изменения в логику класса. Зачастую, нам надо создать экземпляр класса, но с какими то нашими специфичными полями или методами. В таких случаях обычно говорят о наследовании, но я покажу другой вариант. Мы должны понимать, что функция-конструктор такой же объект javascript и мы вольны добавлять в него любые дополнительные поля и методы. Давайте добавим в него метод который будет возвращать нам новый конструктор, при этом инициализируя какие то наши поля.

   1:  var SomeClass = function(){
   2:      this.id = 0;
   3:      this.name = 'name of instance';
   4:  };
   5:   
   6:  SomeClass.extend = function(options){
   7:      return function(){
   8:          var elt = new SomeClass();
   9:          for(var i in options){
  10:              elt[i] = options[i];
  11:          }
  12:          return elt;
  13:      }
  14:  };
  15:   
  16:  var instance  = new SomeClass();
  17:   
  18:  var SomeClass2 = SomeClass.extend({desc:'description', log:function(){console.log(this);}});
  19:  var instance2  = new SomeClass2();
  20:   
  21:  instance2.log();

В результате выполнения кода получим следующее

image

Похожий механизм используется в backbone. Но давайте обо всем по порядку.

Backbone. Начало.

Сама библиотека изначально подразумевала использование паттерна MVC (Model-View-Controller) в приложении. Однако, как мне пояснили на конференции, в последующих версиях контроллеры были исключены из библиотеки, вместо них стали использовать роутеры. Аббревиатура паттерна сама не изменилась, но изменился смысл MVC (Model-View-Collection), что подчеркивает важность коллекций в библиотеке. От Жени Абросимова я узнал и другую версию этого паттерна MVW (Model-View-Whatever), это значит, что библиотека более не затачивается на конкретную архитектуру приложения и может органично работать с другими инструментами. Также библиотека имеет несколько зависимостей – это библиотеки underscore и jquery (вместо jquery можно использовать библиотеку zepto).

Немного теории + много практики.

Как Вы уже догадались, основные действующие лица в backbone – это модель, представление и коллекция. Начнем с модели.

Модель, как и в других паттернах (таких, как MVC, MVP, MVVM, etc) – представляет собой хранилище единицы данных, с которыми планируется работать. В списке товаров это будет товар, в информации по заказам (или заказу) – заказ. Для моделей в backbone есть специальный класс – Backbone.Model. Этот объект, как нетрудно догадаться, является просто функцией-конструктором

   1:  var Model = Backbone.Model = function(attributes, options) {
   2:    var defaults;
   3:    var attrs = attributes || {};
   4:    options || (options = {});
   5:    ..........  
   6:  };

Эта функция принимает в качестве параметров пару аргументов – атрибуты нашей модели и объект с параметрами. Как видно, оба аргумента являются необязательными. Однако, напрямую нам его использовать нет смысла – ведь нам наверняка понадобятся различные методы. Тут нам пригодится показанный выше приём, где мы расширяли конструктор (отмечу, что в данном случае extend – метод библиотеки backbone. Наш метод extend я написал только для общего понимания работы метода библиотеки). Поскольку мы делаем CMS, давайте определим модель для страницы

   1:  // Конструктор для страницы
   2:  var PageItem = Backbone.Model.extend({
   3:   
   4:      // Значения по умолчанию
   5:      defaults:{
   6:          title:'default page',
   7:          hash:'index',
   8:          content:'default content'
   9:      }
  10:  });

Выглядит довольно просто, верно? Всё, что нам нужно знать о странице – это заголовок, контент страницы и хеш, по которому будем эту страницу отображать. Теперь об отображении. Я предполагаю, что наше приложение будет выглядеть как то так:

image

image

Тут мы видим сверху меню навигации для страниц, далее поля ввода для создания новой страницы, и ниже – заголовок страницы, контент страницы и кнопку для удаления страницы (заголовок и контент редактируемые). Очевидно, для того, чтобы отобразить страницу, нам понадобятся 2 представления – это представление в меню навигации и представление для редактирования.

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

image

Сам элемент LI будет выступать контейнером для представления, а рендер будет происходить в ссылку. Для этого я в HTML разметке определил шаблон, по которому будет происходить формирование представления. Работа с шаблонами – это функционал библиотеки underscore.

   1:  <script type="text/template" id="page-nav-template">
   2:      <a href="#pages/<%=hash%>"><%=title%></a>
   3:  </script>

Код шаблона тривиален, потому перейдем к коду представления. Отмечу, что ниже приведен код конструктора представления, экземпляры мы ещё не создавали.

   1:  // Конструктор представления для рендеринга страницы в области навигации
   2:  var PageViewNav = Backbone.View.extend({
   3:      // Тег контейнера, в котором будет рендериться все представление
   4:      tagName:'li',
   5:   
   6:      // Инициализация.
   7:      initialize: function() {
   8:   
   9:          // Перерисовываем если модель изменяется
  10:          this.listenTo(this.model, 'change', this.render);
  11:   
  12:          // Удаляем представление, если модель удалена
  13:          this.listenTo(this.model, 'destroy', this.remove);
  14:      },
  15:   
  16:      // Мы заводим собственное поле template - своеобразный кеш для переменной.
  17:      // То есть мы могли бы и при рендеринге каждый раз получать этот темплейт, но чтобы
  18:      // не быть расточительными, сохраним эту переменную сразу
  19:      template: _.template($('#page-nav-template').text()),
  20:   
  21:      // Метод занимается отображением нашего представления.
  22:      // По сути мы просто применяем нашу модель к шаблону (темплейту)
  23:      render:function(){
  24:          this.$el.html(this.template(this.model.attributes));
  25:          return this;
  26:      }
  27:  });
Я постарался написать как можно более информативные комментарии, чтобы было понятно, что делается в коде. В принципе, пока всё выглядит очень просто.

Теперь дело за вторым представлением модели – тем, где можно эту модель изменять/удалять. Отличия этого представления в том, что мы выполняем некоторые манипуляции с моделью, в зависимости от действий пользователя. Дело происходит так: представление генерирует разметку и в этой разметке подписывается на события элементов. Как только разметка будет помещена в DOM, пользователь её увидит и сможет с ней работать. При определенных действиях будут вызываться методы представления (при помощи событий), и в этих методах мы будет моделью манипулировать. При изменении модели, сама модель будет генерировать различные события. Поскольку оба представления у нас подписаны на изменения модели, они будут реагировать. Например, если мы изменим заголовок страницы, то должны обновиться все представления, которые отображают эту страницу. Дальше, в принципе, всё аналогично предыдущему примеру. Вот шаблон для отрисовки:

   1:  <script type="text/template" id="page-view-template">
   2:      <h3>
   3:          <div class="title" contenteditable="true"><%=title%></div>
   4:      </h3>
   5:      <p>
   6:          <div role="form">
   7:              <div class="form-group">
   8:                  <div class="view" contenteditable="true"><%=content%></div>
   9:              </div>
  10:              <button type="button" class="btn btn-default" id="del">Delete</button>
  11:          </div>
  12:      </p>
  13:  </script>

Как видно, я использую атрибут contenteditable для возможности редактирования. В принципе, это не обязательно, можно использовать любые средства. Далее, код представления:

   1:  // Конструктор представления для рендеринга контента страницы
   2:  var PageView = Backbone.View.extend({
   3:   
   4:      // Тег контейнера, в котором будет рендериться все представление
   5:      tagName:'div',
   6:   
   7:      // Привязка событий
   8:      events: {
   9:          'blur div.view': 'save',
  10:          'blur div.title': 'save',
  11:          'click button#del': 'delete'
  12:      },
  13:   
  14:      // Инициализация.
  15:      initialize: function() {
  16:   
  17:          // Перерисовываем если модель изменяется
  18:          this.listenTo(this.model, 'change', this.render);
  19:   
  20:          // Удаляем представление, если модель удалена
  21:          this.listenTo(this.model, 'destroy', this.remove);
  22:      },
  23:   
  24:      // Мы заводим собственное поле template - своеобразный кеш для переменной.
  25:      // То есть мы могли бы и при рендеринге каждый раз получать этот темплейт, но чтобы
  26:      // не быть расточительными, сохраним эту переменную сразу
  27:      template: _.template($('#page-view-template').text()),
  28:   
  29:      // Метод занимается отображением нашего представления.
  30:      // По сути мы просто применяем нашу модель к шаблону (темплейту)
  31:      render:function(){
  32:          document.title = this.model.get('title');
  33:          this.$el.html(this.template(this.model.attributes));
  34:          return this;
  35:      },
  36:   
  37:      // Метод сохраняет состояние нашей модели, вызывает событие модели change.
  38:      // Срабатывает по событию.
  39:      save:function(){
  40:          this.model.save({content:$('div.view').html(), title:$('div.title').text()});
  41:      },
  42:   
  43:      // Метод для удаления модели. Срабатывает по событияю.
  44:      delete:function(){
  45:          this.model.destroy();
  46:      }
  47:  });
Здесь все понятно, отмечу только своеобразную привязку к событию – ‘событие селектор’ : ‘название метода-обработчика’.

Отлично, теперь у нас есть классы для модели и для представлений. Но модель у нас будет не одна – у нас будет некое множество моделей, и их надо где то хранить. Для этого (и не только), существуют так называемые коллекции. Коллекции не только хранят модели, они могут хранить их в определенном порядке, они следят за изменениями моделей, они могут синхронизировать данные моделей с сервером по протоколу REST или хранить их каким другим способом. Нас интересует хранение моделей в локальном хранилище, потому я использую такое дополнение к библиотеке, как Backbone.localStorage

   1:  // Конструктор коллекции страниц
   2:  var PageCollection = Backbone.Collection.extend({
   3:      // Указываем, с каким типом модедей будем работать
   4:      model:PageItem,
   5:   
   6:      // Устанавливаем хранилище для элементов страниц - локальное хранилище
   7:      localStorage: new Backbone.LocalStorage("pages")
   8:  });

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

image

Вот HTML, с которым нам придется работать:

   1:  <div class="app">
   2:      <div class="container">
   3:          <div class="starter-template">
   4:   
   5:              <div role="form" class="form-inline">
   6:                  <div class="form-group">
   7:                      <label class="sr-only" for="title">Title</label>
   8:                      <input type="text" class="form-control" id="title" placeholder="Enter title">
   9:                  </div>
  10:   
  11:                  <div class="form-group">
  12:                      <label class="sr-only" for="hash">Hash</label>
  13:                      <input type="text" class="form-control" id="hash" placeholder="Enter hash">
  14:                  </div>
  15:   
  16:                  <div class="form-group">
  17:                      <label class="sr-only" for="content">Content</label>
  18:                      <input type="text" class="form-control" id="content" placeholder="Enter content">
  19:                  </div>
  20:   
  21:                  <button type="button" class="btn btn-default" id="add">Add</button>
  22:              </div>
  23:   
  24:   
  25:          </div>
  26:      </div>
  27:   
  28:      <div class="container">
  29:          <div class="starter-template">
  30:              <div class="row">
  31:                  <div class="content"></div>
  32:              </div>
  33:          </div>
  34:      </div>
  35:  </div>

Конечно, довольно путанно. Но я отмечу 3 вещи: поля для ввода данных с кнопкой и <div class="content"></div>. Поля и кнопка предназначены для добавления страниц, а указанный див – в него будем рендерить представление модели для редактирования. Вот код этого представления:

   1:  // Коллекция страниц. Инициализируем её тут, так как далее в определениях
   2:  // предсталения и роутере будем эту коллекцию использовать
   3:  var pages = new PageCollection();
   4:   
   5:  // Конструктор предсталения нашего приложения
   6:  var AppView = Backbone.View.extend({
   7:      el: $('.app'),
   8:      events: {
   9:          // Привязываемся к кнопке - прописываем метод для добавления страницы
  10:          'click button#add': 'add'
  11:      },
  12:      initialize: function () {
  13:   
  14:          // Привязываемся к событияю добавления страницы в коллекцию страниц
  15:          pages.bind('add', this.addItem);
  16:   
  17:          // Загружаем сохраненные ранее страницы из локального хранилища
  18:          pages.fetch();
  19:      },
  20:   
  21:      // Событие добаления элемента в коллекцию.
  22:      // Тут нужно создать представление для модели, выполнить рендер и
  23:      // сохранить результат в DOM
  24:      addItem: function (model) {
  25:          var view = new PageViewNav({model: model});
  26:          view.render();
  27:          $('.nav.navbar-nav').append(view.el);
  28:      },
  29:   
  30:      // Добавление новой страницы. По сути все просто -
  31:      // получаем введеные пользователем значения полей,
  32:      // добавляем новую модель в коллекцию и очищаем поля
  33:      add: function () {
  34:          pages.create({
  35:              content: $('#content').val(),
  36:              title: $('#title').val(),
  37:              hash: $('#hash').val()
  38:          });
  39:          $('#content').val('');
  40:          $('#title').val('');
  41:          $('#hash').val('');
  42:      }
  43:  });

Тут все ясно из комментариев. Отмечу ещё раз, что перед кодом представления мы инициализировали экземпляр коллекции, так как в самом представлении нам нужно будет с ней взаимодействовать.

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

   1:  // Конструктор роутера
   2:  var WorkspaceRouter = Backbone.Router.extend({
   3:   
   4:      routes: {
   5:          // маршрут для перехвата выбранной страницы
   6:          "pages/:hash":   "page",  // #pages/hash
   7:   
   8:          // пытаемся отловить все другие маршруты, чтобы
   9:          // если не удастся сопоставить маршут странице - просто
  10:          // затереть место, где выводится текущая страница.
  11:          '':"default",
  12:          '*query':"default"
  13:      },
  14:   
  15:      // Если по хешу мы можем найти нашу страницу - то мы должны её отрендерить,
  16:      // иначе просто показать пустое место
  17:      page: function(hash) {
  18:          var page =  pages.findWhere({hash:hash});
  19:          if (page){
  20:              var view = new PageView({model: page});
  21:              view.render();
  22:              $('.content').html(view.el);
  23:          }
  24:          else
  25:              $('.content').empty();
  26:      },
  27:   
  28:      // затираем на месте страницы все контролы
  29:      default:function(){
  30:          $('.content').empty();
  31:      }
  32:  });

Комментарии в коде довольно подробные. Для законченности приложения, осталось сделать всего пару вещей. Во первых, нужно создать экземпляр главного представления – при этом мы сразу подпишемся на все события и загрузим данные в коллекцию (при этом автоматически произойдет отрисовка всех моделей). Затем мы создадим экземпляр роутера, но чтобы он заработал, нужно будет вызвать метод Backbone.history.start(); В общем, вот весь код:

   1:  // Предсталение нашего приложения - представление высокого уровня
   2:  // Инициализируем его первым, так как тут подкачиваются данные из
   3:  // локального хранилица
   4:  var app = new AppView();
   5:   
   6:  // Роутер
   7:  var router = new WorkspaceRouter();
   8:   
   9:  // Запускаем историю.
  10:  Backbone.history.start();

Вот, собственно, и всё. Можно запускать и лицезреть результаты УлыбкаПри перезагрузке страницы, все данные должны подкачиваться, роутеры отрабатываться, представления отображаться.

image

На этот раз весь исходный код я разместил на github, демо можно поглядеть тут. По сути, интерес представляют только 2 файла: разметка и код приложения.

Отдельная благодарность организаторам, докладчикам и просто участникам dotnetconf, было здорово и познавательно.

Дополнительные материалы и использованная литература

  1. Наследование для классов в JavaScript
  2. Библиотека backbone (ангрус)
  3. Очень помогают исходники с аннотациями (анг
  4. Доклад Константина Зотова ( слайды, демо )
  5. Доклад Евгения Абросимова. Жаль, пока нет слайдов.
  6. Мега книжка “Веб приложения на javascript” (о которой я уже писал)
  7. Отличный пример Todos, написанный неким Jérôme Gravel-Niquet

3 комментария:

  1. Анонимный16 июля 2015 г., 12:26

    Спасибо,очень помогло разобраться.

    ОтветитьУдалить
  2. Спасибо, жалко, что картинки потерялись.

    ОтветитьУдалить
    Ответы
    1. Хм, странно. У меня всё показывается.

      Удалить