Backbone.js

Итак, я твердо решил написать хорошую книгу по Backbone.js. Делать это я буду в свободное время, поэтому возможно новые главы будут появляться не так часто как хотелось бы. Тем более, что я хочу написать хорошую книгу. А это значит, что главным приоритетом для меня будет не скорость, а качество. Также, я не исключаю, что буду возвращаться к предыдущим главам для их улучшения. Поэтому вполне возможно, что прочитав ее раз, через некоторое время мы сможете обнаружить для себя в ней что-то новое. Этим и замечательна online публикация. О всех важных изменениях буду публиковать сообщения в блоге. Поэтому подписывайтесь на RSS и следите за обновлениями. Очень надеюсь, что данная книга окажется для вас полезной. Обсудить ее можно здесь.

С уважением
Дмитрий Гавриков

Для кого эта книга?

Эта книга для начинающих знакомиться с Backbone.js. Более того, основная ее цель — сделать это знакомство как можно более приятным и быстрым, чтобы вы были в состоянии в дальнейшем самостоятельно использовать этот фреймворк для своих целей. Однако, данная книга не является книгой для начинающих в Javascript. Предполагается, что читатель уже обладает следующими знаниями:

  • знания чистого Javascript
  • опыт ООП
  • манипуляции с DOM и обработка событий с помощью jQuery
  • шаблонизация с использованием Underscore/Lodash
  • организация кода при помощи Require.js (в более поздних главах)

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

Также не стоит воспринимать данную книгу как замену официальной документации. Наоборот, я буду стараться мягко подталкивать вас к ней. Понимаю, что очень не просто сразу начать изучать новую технологию, основываясь только на документации. Иначе бы в подобных книгах не было бы необходимости. Их цель дать базу, после которой просмотр документации станет естественным и неотъемлемым элементом в работе. Надеюсь, что после прочтения моей книги, вы хлопнете себя по лбу и, глядя на сайт с документацией, скажете «как же я мог этого не понимать, это же так просто».

Зачем нужен Backbone.js

И так. Зачем же все-таки нужно использовать фреймворки подобные Backbone.js?

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

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

Однако, это довольно маргинальный случай. Рассмотрим более реалистичный сценарий. Вы пишете свой собственный код, вам не дышит менеджер в затылок, вы применяете ООП, грамотно выделяя сущности в классы, тщательно выбираете имена переменным и функциям и используете систему собственных событий для связи классов между собой. Значит ли это, что Backbone окажется для вас лишним? Не станет ли он просто дополнительной бесполезной нагрузкой или, что еще хуже, не будет ли он мешать? Вовсе нет.

Даже в такой, казалось бы близкой к идеальной, ситуации вам как разработчику придется решать много однотипных вопросов. Какой тип данных использовать для доступа к серверу через Ajax? Сразу тянуть готовый HTML или взять JSON, а уже на стороне клиента самому генерировать HTML? Как организовать код для генерации HTML и вставки его в DOM? По-быстрому добавить строчку jQuery прямо в функции с логикой или же выделить его в отдельную функцию render? А может создать для этого целый отдельный класс Render? А если нам необходимо отображать информацию по-разному в зависимости от обстоятельств, то нам надо их все учитывать прямо в функции render/классе Render или же в каждом случае создавать отдельные функции/классы, отвечающие за отображение? Как осуществлять связь между классами? Экземпляры классов будут создавать собственные события, а «слушающие» их будут на них реагировать, или же лучше напрямую обращаться к методам класса? И это не полный перечень подобных вопросов.

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

Все верно. Однако, представим себе такой проект в котором допустим штук 20 уникальных взаимосвязанных элементов интерфейса. Не независимых, а именно взаимосвязанных. Т.е. когда пользователь чего-то ткнул/написал в одном месте, в нескольких других произошли соответствующие изменения. Каждому типу элемента соответствует свой класс, и в каждом случае мы отвечали на вышестоящие вопросы по-разному в зависимости от обстоятельств.

Представили? Отлично. Теперь вы понимаете, каково это каждый раз заходить в отдельный класс, искать, каким образом он взаимодействует с сервером, где и каким образом хранится состояние класса, в каком месте прописано отображение класса в браузере, что за события он использует, и какие генерирует сам. А если таких уникальных элементов не 20, а 50? А если 100 или больше? Не знаю как у вас, а у меня от таких мыслей начинает зеленеть цвет лица.

Так, а что же тогда делать? Использовать общую конвенцию, по которой все классы устроены одинаковым образом? Но тогда нам придется всегда использовать максимально сложный вариант. Т.е. связь с сервером только в JSON, генерация HTML только на клиенте, даже если он состоит во вставке однострочного <li>, для каждого способа отображения отдельная функция или даже класс, связь между классами с помощью унифицированных событий и т.д. Да, все именно так.

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

Копипастом? Шучу. Заниматься копипастом — глупо и рутинно. Поэтому мы будем использовать наследование. И если в этот момент у вас возникла мысль, а нет ли какого-либо готового инструмента для всего этого, чтобы не создавать свой двухколесный транспорт на мышечной тяге, то могу вас порадовать, что он есть, и имя ему Backbone.js.

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

Рабочая заготовка

Для начала определимся с рабочим файлом, в котором будем писать весь код. Так как примеры являются учебными, то будем использовать самый простой вариант:

<!DOCTYPE HTML>
<html>
  
  <head>
    <meta charset="utf-8" />
    <style>
      // Здесь будет CSS код, если понадобится
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <script>
      // Здесь мы будем писать наш Javascript
    </script>
  </body>

</html>

Примечание: Для экономии места и времени в дальнейшем целиком файл показываться не будет, а будут отдельные куски Javascript, HTML или CSS кода, что подразумевает, что их необходимо вставить в соответствующее место в файле.

Модели (Models)

Объяснять составные части Backbone.js отдельно друг от друга довольно сложно по причине их высокой взаимосвязи. Поэтому, если ранее вы не имели дело с MV* шаблонами проектирования, то поначалу у вас может возникнуть легкое непонимание «зачем это все надо, и как это можно использовать». Это нормально. Наберитесь терпения, и довольно скоро мозаика начнет складываться. Итак, приступим.

Организация данных

Что же такое модель в Backbone.js? Если коротко, то это объект с данными. Не html элементы, а данные в чистом виде. Предположим, что мы создаем нумерованный список объявлений о продаже автомобилей. Что вы себе представляете? Если представили что-то вроде

<ol>
	<li>данные</li>
	<li>данные</li>
	<li>данные</li>
</ol>

то это не данные, а их отображение. Забегая вперед, скажу, что в MV*, и в Backbone в частности, это называется вид. А модель это сами данные, которые вставляются в html код, а также связанная с ними логика. Какие это могут быть данные? Ну например это может быть марка и модель продаваемого автомобиля, год его выпуска, пробег, цена и т.д.

Зачем нам нужны эти данные отдельно от html? Они нужны для удобного доступа к ним, если вдруг нам захочется их поменять или использовать где-то еще. Допустим нам захочется, чтобы список умел сортировать сам себя по цене при нажатии на определенную кнопку. Если бы у нас не было данных отдельно, то пришлось бы цену как-то выделять из html. А это неудобно и грозит трудноуловимыми ошибками.

Теоритически данные можно хранить и в примитивах, но лучше всего для этого подходит объект, так как все данные находятся в одном месте.

Сперва посмотрим как данные можно хранить на примере чистого Javascript.

var Advertisement = function(opts) {
  this.make = opts.make;
  this.model = opts.model;
  this.year = opts.year;
  this.price = opts.price;
};

А теперь представим, что это у нас не совсем доска объявлений, а, допустим, список лотов на аукционе в Японии. По такой причине цены на автомобили логичнее хранить в ценах продавца, т.е. в японских иенах. Однако покупателям удобнее видеть цены в рублях, а иены, если и показывать, то справочно. В таком случае нам нужна функция, которая бы конвертировала иены в рубли. Куда ее записать? В класс Advertisement? Вполне возможно, но в таком случае каждый экземпляр класса будет содержать свой собственный экземпляр функции, что не очень экономично, особенно, если экземпляров класса будет много. Правильнее использовать наследование и записать функцию в прототип.

Advertisement.prototype.getPriceInRUB = function(rate) {
  return this.price * rate;
};

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

var Advertisement = function(opts) {
  this.make = opts.make;
  this.model = opts.model;
  this.year = opts.year;
  this.price = opts.price;
};

Advertisement.prototype.getPriceInRUB = function(rate) {
  return this.price * rate;
};

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

var RUBJPY = 0.32; // курс рубля к иене, который мы заранее получили от сервера

alert(ad.getPriceInRUB(RUBJPY)); // 544 000 руб.

Модель в Backbone.js

После того как мы рассмотрели пример организации данных в чистом Javascript, самое время посмотреть, как реализовать подобное средствами Backbone.js.

Данные в программах, написанных с использованием Backbone.js, хранятся в моделях — конструкторах объектов с уже готовым набором дополнительных служебных функций. Создаются модели путем расширения базовой модели, прописанной в Backbone.js.

var Model = Backbone.Model.extend();

Таким образом, мы создали новый конструктор (или класс) Model, который в данном конкретном случае ничем не отличается от базового класса модели, так как мы его ни чем не расширили. Для этого необходимо передать функции extend объект с дополнительными параметрами. Если вернемся к нашим объявлениям, модель могла бы быть описана вот так.

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

Доступ к атрибутам модели

Если посмотрим на код выше, то станет ясно, что появился новый объект, который мы записали в свойство defaults. Как видно из названия, это данные, которые будут у экземпляра класса по умолчанию. Согласен, глупо передавать по умолчанию данные какого-то конкретного объявления. Но применительно к нашему примеру трудно представить вообще какие-то осмысленные значения по умолчанию. В реальном приложении скорее всего их бы не было вовсе. Но здесь они просто ради образовательной цели, чтобы вы знали, что возможность задавать значения по умолчанию есть.

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

Перейдем к функции getPriceInRUB. Как видим, this.price превратилось в this.get('price'). Это произошло потому, что данные в модели не хранятся напрямую в свойствах объекта, а находятся в виде объекта в свойстве attributes. Т.е. мы вполне могли написать this.attributes.price, но так лучше не делать, а пользоваться встроенной функцией get. Во-первых, это короче. А во-вторых, модель способна самостоятельно отслеживать изменения своих данных. Как вы наверняка догадались, если есть get, то должна быть и set. Так вот при вызове set модель создает событие change, с помощью которого очень удобно отслеживать изменения в модели. Если бы мы изменили свойство напрямую, то событие бы не создалось. Но об этом чуть позже.

Теперь когда мы описали класс, самое время создать экземпляр этого класса. Создается он стандартным способом при помощи new, при этом конструктору передается объект с данными, которые попадут в экземпляр класса.

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

alert(ad.get('make')); // Mazda

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

ad.set('price', 1500000);
ad.set({price: 1500000});

Как правило первую предпочитают, если необходимо изменить только один атрибут, а вторую — если сразу несколько. Как вы уже узнали, при обращении к функции set создается событие change. При этом событие произойдет только при изменении значения. Т.е. если бы эти две строчки стояли в коде одновременно, то событие change было бы вызвано только один раз, так как во втором случае изменения атрибута не произошло (цена-то одна и та же). Если изменяется сразу несколько атрибутов, то событие change будет вызвано тоже только один раз. Также следует понимать, что те атрибуты, которые мы передали при создании экземпляра модели в defaults, не единственные, которые могут у нее быть. Т.е. если мы задали по умолчанию 2 каких-либо атрибута, то ничто не помешает нам задать любые дополнительные уже после создания экземпляра модели при помощи метода set.

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

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

ad.on('change', function() {
  alert('Новая цена: ' + this.get('price'));
});

ad.set('price', 1500000); // "Новая цена: 1500000"
ad.set({price: 1500000}); // событие change не будет вызвано
ad.set('price', 1600000); // "Новая цена: 1600000"

Однако иногда нам может понадобиться возможность поменять значение атрибута «по-тихому», т.е. без вызова события change. Для этого необходимо передать функции set дополнительный аргумент в виде объекта (его в документации называют options) {silent: true}. В таком случае наши две эквивалентных записи примут следующий вид.

ad.set('price', 1500000, {silent: true});
ad.set({price: 1500000}, {silent: true});

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

var attrs = ad.toJSON();
console.log(attrs);

Несмотря на название, метод toJSON возвращает не JSON строку, а обычный Javascript объект, в свойствах которого записаны атрибуты модели. Однако, если же вам нужна именно JSON строка, то ее можно получить с помощью функции JSON.stringify, которой передается в качестве аргумента объект Javascript. Это не происки злых разработчиков Backbone.js, желающих вас запутать, а реализация JSON.stringify, полагащегося на toJSON, для своей работы в Javascript API. Так что ничего не остается, кроме как запомнить эту особенность.

var attrs = ad.toJSON();
console.log(JSON.stringify(attrs)); // {"make":"Mazda","model":"Atenza","year":2007,"price":1600000}

Чтобы удалить атрибут из модели, существует метод unset. Этот метод при срабатывании вызывает событие change. Также как и метод set он может принимать объект options. К примеру, чтобы удалить из нашей модели атрибут ‘year’ без вызова события change, то понадобилась бы следующая запись.

ad.unset('year', {silent: true});

Отслежнивание изменений атрибутов в модели

Мы уже только что рассмотрели простой вариант отслеживания изменений атрибутов. При изменении значения атрибута на модели срабатывает событие change. Это очень удобно и здорово. Но как быть, если нам важно отслеживать не все изменения, а изменения только конкретных атрибутов? И более того, что делать, если нам необходимо по-разному реагировать на изменения разных атрибутов? В Backbone.js для этого есть простой способ — после названия события change через двоеточие указывается название атрибута, на изменения которого мы хотим реагировать. Backbone самостоятельно определит, какой атрибут был изменен, и если это наш случай, то запустит соответствующий обработчик. Изменим предыдущий код так, чтобы он реагировал только на изменения цены.

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

ad.on('change:price', function() {
  alert('Новая цена: ' + this.get('price'));
});

ad.set('price', 1500000); // "Новая цена: 1500000"
ad.set({price: 1500000}); // событие change не будет вызвано
ad.set('price', 1600000); // "Новая цена: 1600000"

ad.set('year', 2006); // обработик не будет вызван, так как мы изменили атрибут отличный от 'price', а если бы мы попытались сделать тоже самое в предыдущем варианте кода, то получили бы alert "Новая цена: 1600000", что было бы ошибкой, ведь цену мы не меняли, а изменили только год.

Инициализация

Если при расширении класса модели передать функцию в свойстве initialize, то данная функция будет вызываться каждый раз при создании, нового экземпляра класса.

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

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

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

ad.set('price', 1500000); // "Новая цена: 1500000"
ad.set({price: 1500000}); // событие change не будет вызвано
ad.set('price', 1600000); // "Новая цена: 1600000"

ad.set('year', 2006); // обработик не будет вызван, так как мы изменили атрибут отличный от 'price', а если бы мы попытались сделать тоже самое в предыдущем варианте кода, то получили бы alert "Новая цена: 1600000", что было бы ошибкой, ведь цену мы не меняли, а изменили только год.

Теперь нам не надо назначать обработчики на экземпляр класса. Это будет сделано автоматически сразу при создании экземпляров модели.

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

Валидация значений атрибутов

Любое ли значение можно присваивать атрибутам? Как вам например производитель автомобилей в виде числа 1000? Да, конечно, когда нас поработят наши компьютеры, определять производителей по некоему id в виде числа станет вполне логичным, но пока этого не произошло, это выглядит несколько странно. А как вам цена в виде числа -5000? Ну это-то уже ни в какие ворота. Однако, вы скажете, что нужно следить за тем, какие значения задаются, и будете правы. Но почему бы не отсечь сразу заведомо неверные варианты? Тем более, что Backbone.js предоставляет для этого простые встроенные средства.

Если при создании класса модели расширить базовую модель Backbone.js свойством validate и передать в него функцию, то первым аргументом ей будет передан объект с атрибутами модели. Тот самый, который мы получали при помощи метода toJSON. Данная функция работает следующим образом. Если она ничего не возврщает, то значит установка атрибута прошла успешно. В таком случае метод set вернет ссылку на экземпляр модели, для которой он был вызван, после смены атрибута. А если функции validate будет указано что-то вернуть, то set вернет false, и изменения значения атрибута не будет. Это очень удобно, поскольку позволяет использовать метод set в конструкции if в качестве условия, так как при удачном изменении параметра метод set вернет объект, что эквивалентно true, а при неудачном — false. Посмотрим на примере, как нам не допустить установления отрицательной цены на наши автомобили.

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });
  },
  validate: function(attrs) {
    if (attrs.price < 0) {
      return 'Цена не может быть отрицательной'
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

Обратите внимание, что мы возвращаем строку при провалившейся валидации с описанием ошибки. Однако, как ее получить, если метод set в таком случае вернет false, а не эту строку? Это может быть полезно, например, для того, чтобы вывести ее на экране пользователя, который ввел неверное значение. Ее мы можем получить, навешав обработчик на специальное событие. Но прежде чем продолжить, вам необходимо знать, что начиная с версии 1.0.0 в Backbone.js произошли существенные изменения в этом вопросе. Поэтому мы с вами рассмотрим оба варианта, так как возможно вы еще столкнетесь со старым кодом.

До версии 1.0.0

  1. Валидация происходит как при вызове метода save (этот метод сохраняет модель на сервере, о нем мы поговорим в следующих главах), так и метода set
  2. Генерируется событие error

После версии 1.0.0

  1. Валидация происходит всегда при вызове метода save, но по умолчанию не происходит при вызове метода set. Чтобы валидация проводилась и при вызове метода set, то необходимо при его вызове передать в объекте options {validate:true} (в том самом, в котором мы передавали {silent: true})
  2. Вместо события error генерируется событие invalid

Пример кода для версии до 1.0.0
Примечание: данный код не будет корректно работать в нашем рабочем файле. Если вы хотите посмотреть на его работу, то замените значение версии Backbone.js в атрибуте src с 1.1.0 на 0.9.9

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });

    this.on('error', function(model, error) {
      alert(error);
    });
  },
  validate: function(attrs) {
    if (attrs.price < 0) {
      return 'Цена не может быть отрицательной';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

ad.set('price', -1500000); // "Цена не может быть отрицательной"

Пример кода для версии после 1.0.0

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });

    this.on('invalid', function(model, error) {
      alert(error);
    });
  },
  validate: function(attrs) {
    if (attrs.price < 0) {
      return 'Цена не может быть отрицательной';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
});

ad.set('price', -1500000, {validate:true}); // "Цена не может быть отрицательной"

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

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });

    this.on('invalid', function(model, error) {
      alert(error);
    });
  },
  validate: function(attrs) {
    if (attrs.price < 0) {
      return 'Цена не может быть отрицательной';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000
}); // "Создан новый экземпляр модели объявления"

ad.set('price', -1500000, {validate:true}); // "Цена не может быть отрицательной"
ad.set('price', 1500000); // "Новая цена: 1500000"
ad.set({price: 1500000}); // событие change не произойдет
ad.set('price', 1600000, {silent:true}); // цена изменится, но события change не произойдет

ad.set('year', 2006); // обработик не будет вызван, так как мы изменили атрибут отличный от 'price'

var RUBJPY = 0.32; // курс рубля к иене, который мы заранее получили от сервера

alert(ad.getPriceInRUB(RUBJPY)); // 512 000 руб.

Виды (Представления, Views)

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

Создание вида

Также как и модели, виды создаются путем расширения базового вида, предоставленного Backbone.js.

var AdvertisementView = Backbone.View.extend();
var advertisementView = new AdvertisementView;
console.log(avertisementView);

Если мы выведем получившейся экземпляр вида в консоль, то помимо разных служебных функций обнаружим два свойства el и $el.

Свойство el

Свойство el является ключевым у любого вида. Это родительский DOM элемент, в котором мы будем отрисовывать наш вид. По умолчанию Backbone.js создает div, но мы легко можем назначить его абсолютно любым DOM элементом по своему усмотрению. Делается это при помощи свойства tagName, которое мы передаем при расширении базового вида. Аналогично мы можем задать класс (атрибут class) будущего элемента и его идентификатор (атрибут id).

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  className: 'list-item',
  id: 'list-id'
});
            
var advertisementView = new AdvertisementView;
console.log(advertisementView.el); // <li id="list-id" class="list-item">

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

Если нужный вам элемент уже существует, то можно привязать вид к нему, а не создавать новый элемент. Сделать это можно передав в свойстве el при расширении вида CSS селектор, соответствующий нужному элементу.

var AdvertisementView = Backbone.View.extend({
  el: '#list-1'
});

Помимо свойства el, у каждого вида, есть свойство $el, которое, как вы наверняка догадались, является тем же el, но уже обернутым в функцию jQuery. Это очень удобно, так как избавляет нас от необходимости каждый раз оборачивать el, когда нам нужны возможности jQuery.

Связь вида с моделью

Мы создали вид, но пока он еще понятия не имеет о модели, с которой мы бы хотели его связать. Связь вида с моделью осуществляется путем присваивания в свойство model экземпляра вида ссылки на экземпляр модели. Таким образом эта связь устанавливается не на уровне классов, а на уровне экземпляров моделей и видов.

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
});
            
var advertisementView = new AdvertisementView({ model: ad });
alert(advertisementView.model.get('make')); // "Mazda"

Обратите внимание, что теперь у вида появился доступ к связанной с ним модели через свойство model. И через эту связь можно вызывать методы модели, получать и изменять ее атрибуты. В общем делать все, что нам может понадобиться. Однако, злоупотреблять этим не стоит. Если мы будем активно управлять моделью через вид, то получится, что мы переносим логику, связянную с данными, в логику вида, что хотя и возможно, но архитектурно неправильно. Каждый объект должен заниматься тем чем положено. Приведем простой пример. Допустим, нам понадобилось из вида (т.е. можно предположить, что пользователь нажал на соответствующую кнопку в браузере) вывести цену, приходящуюся на 1 пройденный километр. Мы могли бы сделать это вот так.

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  getPricePerKilometerTravelled: function() {
    return this.model.get('price') / this.model.get('odometer');
  }
});

var advertisementView = new AdvertisementView({ model: ad });
alert(advertisementView.getPricePerKilometerTravelled()); // 22.86

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

var Advertisement = Backbone.Model.extend({
  defaults: {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });

    this.on('invalid', function(model, error) {
      alert(error);
    });
  },
  validate: function(attrs) {
    if (attrs.price < 0) {
      return 'Цена не может быть отрицательной';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  },
  getPricePerKilometerTravelled: function() {
    return this.get('price') / this.get('odometer');
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var AdvertisementView = Backbone.View.extend({
  tagName: 'li'
});

var advertisementView = new AdvertisementView({ model: ad });
alert(advertisementView.model.getPricePerKilometerTravelled()); // 22.86

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

Функция render

Функция render определяет отображение вида в браузере, но при этом не является обязательной. Приведем пример ее использования. Допустим мы хотим сформировать элемент списка для показа нашего объявления из данных, которые у нас имеются в модели.

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  render: function() {
    this.$el.html('Продается ' + this.model.get('make') + ' ' + this.model.get('model') + ' за ' + this.model.get('price') + ' иен');
  }
});
      
var advertisementView = new AdvertisementView({ model: ad });
advertisementView.render(); // вызываем метод render, чтобы сформировать наш элемент списка
console.log(advertisementView.el); // <li>Продается Mazda Atenza за 1600000 иен</li>

Разберем код по порядку. Функция render состоит из одной строчки, в которой мы собираем html код будущего элемента списка. Так как $el у нас является обернутым родительским элементом вида, то мы можем воспользоваться методом jQuery html. Важно понимать, что функция render не вызывается автоматически. Мы ее только определили. Поэтому после этого мы должны ее вызвать, что мы и делаем после создания экземпляра вида.

Однако мы могли бы сделать так чтобы вид сам вызывал функцию render при своем создании. Для этого надо прописать ее вызов в функции initialize. Как вы помните ее можно назначать не только у моделей, но и у видов, коллекций и роутеров.

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.render();
  },
  render: function() {
    this.$el.html('Продается ' + this.model.get('make') + ' ' + this.model.get('model') + ' за ' + this.model.get('price') + ' иен');
  }
});

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

Хорошей практикой является возвращать this в конце функции render. Это позволяет продолжать цепочку наподобие тех, которые используются в jQuery, и экономить на писанине одной строки с вызовом render. В таком случае наш код примет такой вид.

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  render: function() {
    this.$el.html('Продается ' + this.model.get('make') + ' ' + this.model.get('model') + ' за ' + this.model.get('price') + ' иен');

    return this;
  }
});
      
var advertisementView = new AdvertisementView({ model: ad });
console.log(advertisementView.render().el); // <li>Продается Mazda Atenza за 1600000 иен</li>

Еще одной особенностью функции render является, то что если el не ссылается на существущий DOM элемент, то никаких изменений после ее вызова в окне браузера не произойдет, так как мы только создали новый элемент, но еще не вставили его в DOM. Это может смущать поначалу, если вы привыкли к тому, что ваша функция render занимается не только формированием html кода, но и его вставкой в соответствующее место на странице. В Backbone это не так. Когда мы дойдем до коллекций, вы поймете, что такое поведение render логично и правильно.

Применение шаблонизации в видах

Обратили внимание, какая длинная строка у нас получилась в функции render? А ведь мы даже не разместили в нашем элементе списка всей имеющейся информации в модели. Добавим сюда еще необходимые для оформления теги span, strong, em и т.д., и наша функция превратится в кашу. Чтобы этого не произошло, применяется шаблонизация. Backbone.js умеет работать со многими движками шаблонов, включая Underscore.js, Mustache.js, Haml.js и Eco. Здесь мы будем использовать шаблонизатор Underscore.js, так как он является для Backbone.js встроенным. Но знать, что при необходимости можно поменять шаблонизатор на более подходящий, бесспорно приятно.

У видов нет предопределенного свойства для хранения функции шаблонов, но как правило для этого используют свойство template. Преобразуем наш код с использованием шаблонов.

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  template: _.template('Продается <%= make %> <%= model %> за <%= price %> иен'),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
	    
    return this;
  }
});
		        
var advertisementView = new AdvertisementView({ model: ad });
console.log(advertisementView.render().el); // <li>Продается Mazda Atenza за 1600000 иен</li>

Обратите внимание, насколько понятнее стал код. Для тех кто не знаком с Underscore или шаблонизаторами в целом, поясню, что здесь происходит. Нижнее подчеркивание _ является псевдонимом объекта Underscore, также как $ является псевдонимом объекта jQuery. У Underscore есть встроенная функция template, в которую передается специальным образом подготовленная строка, которая является лекалом будущего шаблона. Результатом выполнения этой функции становится скомпилированная функция-шаблон, готовая принимать объект со значениями, которые будут вставлены на место соответствующих переменных. Результатом выполнения уже этой функции становится строка с html кодом, которая становится html кодом родительского элемента el.

Однако, если мы захотим добавить дополнительные теги или расширить количество принимаемых аргументов модели, то наш шаблон может тоже прилично разрастись. Его станет непросто понимать и редактировать. В таком случае шаблон выносится либо в html код, либо в отдельный файл. Для работы с файлами удобно использовать RequireJS. Но пока мы этого касаться не будем, а рассмотрим как можно вынести шаблон в html код нашего файла.

Для этого используют тег script с несуществующим типом, чтобы браузер не пытался исполнить его содержимое как Javascript код. Обычно для этого используют тип text/template, чтобы было понятнее, с чем мы имеем дело при просмотре html кода. Затем этому тегу назначается какой-нибудь идентификатор, чтобы можно было его легко найти, и предается в шаблонизатор. Посмотрим на примере.

<script type="text/template" id="ad-template">
  Продается <strong><%= make %> <%= model %></strong> за <em><%= price %></em> иен
</script>
var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
	    
    return this;
  }
});
		        
var advertisementView = new AdvertisementView({ model: ad });
console.log(advertisementView.render().el); // <li>Продается <strong>Mazda Atenza</strong> за <em>1600000</em> иен</li>

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

Объект events

Часто можно услышать, что Backbone.js — не чистый MVC фреймворк. В MVC фреймворке основополагающими являются три компонента: модели (Models), виды, или как их еще называют, представления (Views) и контроллеры (Controllers). Так вот в Backbone отдельной сущности под названием контроллеры нет. Поэтому Backbone.js часто называют MV* фреймворк. Причиной такого расхождения является то, что MVC в Javascript пришли из серверной части. В процессе миграции решено было не создавать отдельные контроллеры, а использовать существующую в Javascript систему событий и наделить полномочиями контроллеров виды. Таким образом, в Backbone.js виды выполняют также роль контроллеров из классического MVC. Осуществляется это при помощи объекта events.

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

Объект events является ассоциативным массивом. Он используется при расширении видов во время их создания. Как и любой ассоциативный массив он представляет из себя набор пар ключ-значение. Ключом является строка в общем виде представленная как ‘событие селектор’, значение — строка с названием вызываемой функции.

Посмотрим работу событий на нашем примере. Но сперва нам надо его немного подправить. Создадим в html нумерованный список, в который будем вставлять наш элемент списка, добавим события в наш вид, немного подправим шаблон и модель, выкинем ненужные теперь вызовы set и get, которые мы делали вручную, посыпем сверху немного CSS, и наше блюдо готово.

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

JS Bin

<!DOCTYPE HTML>
<html>
  
  <head>
    <meta charset="utf-8" />
    <style>
      .car-sold {
        text-decoration: line-through;
      }
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <h1>Список ваших лотов</h1>
    <ol id="ad-list"></ol>
    
    <script type="text/template" id="ad-template">
      <input class="state-toggle" type="checkbox" <%= sold ? 'checked="checked"' : ''%>> Продается <strong><%= make %> <%= model %></strong> <%= year %> года выпуска с пробегом <%= odometer %> км. за <em><%= price %></em> иен <button class="edit-price">Изменить цену</button><button class="delete">Снять с торгов</button>
    </script>
    
    <script>
      // Здесь мы будем писать наш Javascript

var Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  initialize: function() {
    console.log('Создан новый экземпляр модели объявления');

    this.on('change:price', function() {
      alert('Новая цена: ' + this.get('price'));
    });

    this.on('invalid', function(model, error) {
      alert(error);
    });
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  },
  getPricePerKilometerTravelled: function() {
    return this.get('price') / this.get('odometer');
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
}); // "Создан новый экземпляр модели объявления"

var RUBJPY = 0.32; // курс рубля к иене, который мы заранее получили от сервера
      
var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.model.on('change:price', this.render, this);
    this.model.on('destroy', this.remove, this);
  },
  events: {
    'click .state-toggle': 'toggleCarSoldState',
    'click .edit-price': 'editPrice',
    'click .delete': 'destroy'
  },
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    
    return this;
  },
  toggleCarSoldState: function() {
    this.model.toggleCarSoldState();
    
    this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
  },
  editPrice: function() {
    var newPrice = prompt('Введите пожалуйста новую цену', this.model.get('price'));
    
    if (newPrice === null) return;
    
    this.model.set('price', +newPrice, {validate: true});
  },
  destroy: function() {
    this.model.destroy();
  },
  remove: function() {
    this.$el.remove();
  }
});
      
var advertisementView = new AdvertisementView({ model: ad });
$('#ad-list').append(advertisementView.render().el);

    </script>
  </body>

</html>

И так, вы прочитали исходный код, и у вас наверняка возникло много вопросов. Начнем смотреть, что же у нас здесь происходит. Опустим очевидные изменения в CSS и HTML. Превое, мы поменяли шаблон. Теперь он у нас начинается с чекбокса, который отрисовывается помеченным или нет в зависимости от атрибута модели sold. Кстати, подумайте, зачем это нужно, если при нажатии на него он и так будет включаться и выключаться средствами браузера? Дело в том, что атрибуты модели могут быть поменяны где-то еще, и если мы перерисуем наш элемент списка, то нужно, чтобы его вид отражал текущее состояние модели. Также мы добавили две кнопки — «Изменить цену» и «Снять с торгов». Это наши элементы управления.

Теперь спустимся до объекта events. Как мы уже знаем, он имеет следующую структуру.

'событие слектор': 'обработчик'

В качестве события может быть не только click, а любое поддерживаемое jQuery событие, например dbclick, hover, focusin, focusout, keydown и т.д. Если селектор опущен, это значит, что мы слушаем событие на родительском элементе el. Теперь такая тонкость относительно селектора. Селектор всегда ищется в контексте родительского элемента el. Это позволяет ускорить работу приложения и не путать элементы интерфейса нашего вида с элементами других видов и прочими элементами на странице. Иначе говоря, запись выше эквивалентна следующей.

this.$el.find('селектор');

После того как событие сработало на элементе соответствующем селектору, вид исполняет функцию, которую мы определили как обработчик. Рассмотрим наши обработчики поближе.

toggleCarSoldState в AdvertisementView

toggleCarSoldState: function() {
  this.model.toggleCarSoldState();
    
  this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
},

toggleCarSoldState в Advertisement

toggleCarSoldState: function() {
  this.get('sold') ? this.set('sold', false) : this.set('sold', true);
}

Обработчик toggleCarSoldState вызывает аналогичную функцию у модели, которая переключает атрибут sold между true и false. Заметьте, что мы могли бы не назначать модели отдельную функцию, а выполнять эти действия прямо в обработчике, но это было бы неправильно, так как управлять атрибутами — задача модели, а не вида. После этого в зависимости от текущего значения этого атрибута, родительскому элементу вида присваивается CSS класс, который перечеркивает наш элемент списка.

destroy, remove в AdvertisementView

destroy: function() {
  this.model.destroy();
},
remove: function() {
  this.$el.remove();
}

initialize в AdvertisementView

initialize: function() {
  this.model.on('change:price', this.render, this);
  this.model.on('destroy', this.remove, this);
},

Думаю, что понять действия обработчика editPrice не составит особого труда. А вот на обработчике destroy стоит остановиться поподробнее. Он запускает встроенный метод модели destroy, который отправляет запрос на сервер об удалении ее от туда, удалает модель из коллекций (о них мы поговорим чуть позже) и генерирует событие destroy на модели. Благодаря этому мы можем повешать обработчик на модель, который будет срабатывать при наступлении этого события. Что мы и сделали. Наш обработчик remove удаляет родительский элемент, тем самым удаляя элемент списка.

Еще один немаловажный момент. Когда мы навешивали обработчики через on, мы передавали третьим аргументом this. Таким образом мы сохраняем контекст, или по-простому, мы передаем текущую this в функцию, чтобы в ней она ссылалась на тот же объект, что и в момент его передачи, т.е. в нашем случае на будущий экземпляр AdvertisementView. Если бы мы этого не сделали, то внутри этих функций при их вызове в качестве обработчиков, срабатываемых при наступлении событий change:price и destroy, this ссылалась бы на глобальный объект window.

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

Уверен, что опытные тестировщики попробовали ввести пустую строку '' или несколько пробелов ' ', на что получили отказ в виде «Цена должна быть больше 0″. Однако, это не совсем правильная формулировка ошибки. По-хорошему, надо отличать пустые строки и строки с пробелами от нуля и выводить ошибку «Цена должна быть числом». Оставляю это задание вам в качестве домашнего. Уверен, что обладая теми знаниями, которые мы уже освоили, оно не вызовет у вас никаких трудностей.

Когда закончите, можете сравнить свое решение с моим.

<!DOCTYPE HTML>
<html>
  
  <head>
    <meta charset="utf-8" />
    <style>
      .car-sold {
        text-decoration: line-through;
      }
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <h1>Список ваших лотов</h1>
    <ol id="ad-list"></ol>
    
    <script type="text/template" id="ad-template">
      <input class="state-toggle" type="checkbox" <%= sold ? 'checked="checked"' : ''%>> Продается <strong><%= make %> <%= model %></strong> <%= year %> года выпуска с пробегом <%= odometer %> км. за <em><%= price %></em> иен <button class="edit-price">Изменить цену</button><button class="delete">Снять с торгов</button>
    </script>
    
    <script>
      // Здесь мы будем писать наш Javascript

var Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  },
  getPricePerKilometerTravelled: function() {
    return this.get('price') / this.get('odometer');
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
}); // "Создан новый экземпляр модели объявления"

var RUBJPY = 0.32; // курс рубля к иене, который мы заранее получили от сервера
      
var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.model.on('change:price', this.render, this);
    this.model.on('destroy', this.remove, this);
    this.model.on('change:price', function() {
      alert('Новая цена: ' + this.model.get('price'));
    }, this);

    this.model.on('invalid', function(model, error) {
      alert(error);
    }, this);
  },
  events: {
    'click .state-toggle': 'toggleCarSoldState',
    'click .edit-price': 'editPrice',
    'click .delete': 'destroy'
  },
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    
    return this;
  },
  toggleCarSoldState: function() {
    this.model.toggleCarSoldState();
    
    this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
  },
  editPrice: function() {
    var newPrice = prompt('Введите пожалуйста новую цену', this.model.get('price'));
    
    if (newPrice === null) return;
    
    newPrice = $.trim(newPrice);
    if (newPrice === '') newPrice = NaN;
    
    this.model.set('price', +newPrice, {validate: true});
  },
  destroy: function() {
    this.model.destroy();
  },
  remove: function() {
    this.$el.remove();
  }
});
      
var advertisementView = new AdvertisementView({ model: ad });
$('#ad-list').append(advertisementView.render().el);

    </script>
  </body>

</html>

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

Коллекции (Collections)

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

Коллекции в Backbone.js по сути являются массивами экземпляров моделей. Однако, помимо этого у них есть дополнительные фишки, которые делают их вне конкуренции по сравнению с обычными массивами.

Создание коллекций

Коллекции создаются путем расширения базовой коллекции. Чтобы связать коллекцию с моделью, при ее создании в свойстве model передают ссылку на модель. Применительно к нашему примеру это могло бы выглядеть так.

var Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  },
  getPricePerKilometerTravelled: function() {
    return this.get('price') / this.get('odometer');
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement
});

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

Добавление и удаление экземпляров моделей из коллекций

Добавление экземпляров моделей во время создания экземпляра коллекции

Для этого необходимо передать новой коллекции при создании либо ссылку на существующий экземпляр модели, либо объект с параметрами для создания экземпляра модели. Следующие две записи эквивалентны.

var ad = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var advertisementsCollection = new AdvertisementsCollection(ad);
console.log(advertisementsCollection.length); // 1
var advertisementsCollection = new AdvertisementsCollection({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

console.log(advertisementsCollection.length); // 1

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

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

var ad1 = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var ad2 = new Advertisement({
  make: 'Тойота',
  model: 'Королла',
  year: 2010,
  price: 2000000,
  odometer: 40000
});

var advertisementsCollection = new AdvertisementsCollection([ad1, ad2]);
console.log(advertisementsCollection.length); // 2
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  }
]);

console.log(advertisementsCollection.length); // 2

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

Добавление дополнительных экземпляров моделей

Нам часто может понадобится добавить новый экземпляр модели уже после создания коллекции. Для этого у коллекций есть метод add. Аналогично предыдущему варианту он принимает одиночные ссылки на существующие экземпляры модели или массивы с ними. А если у класса коллекции определено свойство model, то он также может принимать сырые данные для создания экземпляров моделей. При этом на модели срабатывает событие add, которое также всплывает на коллекции, в которую оно добавяется.

var ad1 = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var ad2 = new Advertisement({
  make: 'Тойота',
  model: 'Королла',
  year: 2010,
  price: 2000000,
  odometer: 40000
});

var ad3 = new Advertisement({
  make: 'Ниссан',
  model: 'Альмера',
  year: 2010,
  price: 1400000,
  odometer: 60000
});

var advertisementsCollection = new AdvertisementsCollection;

advertisementsCollection.add([ad1, ad2]);
console.log(advertisementsCollection.length); // 2

advertisementsCollection.add(ad3);
console.log(advertisementsCollection.length); // 3
var advertisementsCollection = new AdvertisementsCollection;

advertisementsCollection.add([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

console.log(advertisementsCollection.length); // 3

Удаление экземпляров моделей из экземпляра коллекции

Чтобы удалить экземпляр модели необходимо воспользоваться методом remove. Этот метод точно также как и метод add принимает как одиночные ссылки на экземпляры моделей, так и массивы с ними. При этом на модели срабатывает событие remove, которое также всплывает на коллекции, из которой оно было удалено.

var ad1 = new Advertisement({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

var ad2 = new Advertisement({
  make: 'Тойота',
  model: 'Королла',
  year: 2010,
  price: 2000000,
  odometer: 40000
});

var advertisementsCollection = new AdvertisementsCollection;

advertisementsCollection.add([ad1, ad2]);
console.log(advertisementsCollection.length); // 2

advertisementsCollection.remove(ad2);
console.log(advertisementsCollection.length); // 1

Обновление коллекции целиком

Иногда бывает необходимо обновить экземпляр коллекции разом, а не добавлять или удалять экземпляры моделей группами. Для этого используется метод reset. Аналогично методу add, данный метод принимает ссылки на одиночные экземпляры моделей, сырые данные для создания экземпляров модели, если у класса коллекции определен класс модели в свойстве model, а также массивы с ними. При этом только на коллекции срабатывает событие reset, а индвидиуальных событий add и remove создано не будет.

var advertisementsCollection = new AdvertisementsCollection({
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});

console.log(advertisementsCollection.length); // 1

advertisementsCollection.reset([
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

console.log(advertisementsCollection.length); // 2

Метод reset используется для оптимизации, когда использование индивидуальных событий add, remove слишком затратно.

Доступ к элементам коллекции

Доступ по порядковому номеру в коллекции

Это самый простой способ получить доступ к экземпляру модели в коллекции. Однако, вы не часто будете им пользоваться для доступа к какой-то конкретной модели, так как он не очень надежен. Модели могут удаляться из коллекции, и если удаленная модель была в середине, то все последующие изменят свой порядковый номер. Также при желании можно вставлять экземпляры моделей в начало при помощи unshift, что медленнее, чем add, но тоже может иногда понадобится. Все это приводит к тому, что порядковый номер у экземпляра модели может меняться и довольно часто. Другое дело, если ваша коллекция отсортирована по какому-то признаку (например в нашем случае это могла бы быть цена автомобиля). В таком случае доступ по порядковому номеру становится очень нужным инструментом.

Для доступа к конкретному элементу коллекции используется метод at, в который передается порядковый номер искомого экземпляра модели.

advertisementsCollection.at(0); // первый экземпляр модели в коллекции
advertisementsCollection.at(advertisementsCollection.length - 1); // последний экземпляр модели в коллекции

Доступ по идентификатору

Этот способ используют для доступа к конкретному экземпляру модели независимо от того, где он находится в коллекции. У каждой модели есть свой id, который ей был назначен серверной частью вашего приложения. Если модель была получена с сервера, а не создана на стороне клиента, то у нее уже есть свой id, или если модель была создана на стороне клиента, но была сохранена на сервере. Также id используется для генерации строки URL по умолчанию. Как осуществлять связь с сервером при помощи Backbone, мы рассмотрим позднее, поэтому не переживайте, если пока вы не совсем поняли о чем здесь идет речь. Идентификатор id можно задать вручную, если явно присвоить его экземпляру модели.

Backbone.js считает по умолчанию, что в качестве идентификатора используется атрибут id. Однако серверная сторона может использовать для этого другой атрибут, например '_id'. Для того чтобы Backbone также использовал его в качестве идентификатора, необходимо установить у моделей атрибут idAttribute равный '_id'.

var Advertisement = Backbone.Model.extend({
  idAttribute: '_id',
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  getPriceInRUB: function(rate) {
    return this.get('price') * rate;
  },
  getPricePerKilometerTravelled: function() {
    return this.get('price') / this.get('odometer');
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var ad = new Advertisement({
  _id: 1,
  make: 'Mazda',
  model: 'Atenza',
  year: 2007,
  price: 1700000,
  odometer: 70000
});
      
alert(ad.id);  // 1
alert(ad._id); // undefined
alert(ad.get('id')); // undefined
alert(ad.get('_id')); // 1

Обратите внимание, что при создании экземпляра модели мы использовали _id. Именно этот идентификатор будет использоваться на сервере. Однако на стороне клиента значение этого идентификатора доступно через свойство id. Также доступ к значению идентификатора можно получить, обратившись к атрибуту '_id'.

Если же экземпляр модели был только что создан на стороне клиента, то у него еще нет id. Тем не менее, нам уже может понадобится получить к нему доступ. В таком случае используется cid (от client id), или клиентский идентификатор. Он назначается Backbone автоматически при создании нового экземпляра модели.

Для доступа к экземпляру модели по идентификатору используется метод get. Он принимает в качестве аргумента либо id, либо cid.

var advertisementsCollection = new AdvertisementsCollection([
  {
    id: 1,
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    id: 2,
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    id: 3,
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

console.log(advertisementsCollection.get(2).get('make')); // "Тойота"

Доступ ко всем экземплярам моделей

Иногда нам нужно получить доступ к атрибутам всех экземпляров моделей в коллекции разом. Для этого используется метод toJSON. Вы наверняка помните его по главе, посвященной моделям. Как и там, несмотря на название, он выводит не строку JSON, а в данном случае массив со всеми атрибутами экземпляров моделей.

console.log(advertisementsCollection.toJSON()); 
/* 
[
  {
    make: "Mazda",
    model: "Atenza",
    odometer: 70000,
    price: 1700000,
    sold: false,
    year: 2007
  },
  {
    make: "Тойота",
    model: "Королла",
    odometer: 40000,
    price: 2000000,
    sold: false,
    year: 2010
  },
  {
    make: "Ниссан",
    model: "Альмера",
    odometer: 60000,
    price: 1400000,
    sold: false,
    year: 2010
  }
]
*/

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

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

Как вы помните, жесткой зависимостью Backbone.js является Underscore.js. Это позволило разработчикам наделить модели и коллекции вспомогательными функциями Underscore. И ими можно пользоваться, как будто они являются их собственными. Если вы еще не знакомы с этими методами, то сейчас самое время (документация по Underscore доступна на русском языке также как и по Backbone, поэтому если вас перекинуло на англоязычный сайт просто замените org на ru в адресной строке). А здесь мы рассмотрим только самые основные.

Метод each, или его псевдоним forEach, имеет следующий вид.

list.each(iterator, [context])

Этот метод перебирает все элементы коллекции, вызывая для каждого функцию, которую мы определили в качестве итератора, передавая ей контекст в this, если мы его определили. При этом итератору будут доступны следующие три параметра: (model, index, list). В нашем случае это будет экземпляр коллекции, его порядковый номер и сама коллекция.

advertisementsCollection.each(function(advertisement, index) {
  console.log(index + ': Производитель - ' + advertisement.get('make'));
});

// "0: Производитель - Mazda"
// "1: Производитель - Тойота"
// "2: Производитель - Ниссан"

Вы явно заметили, что формат нашей функции отличается от формата, который указан на сайте Underscore.

_.each(list, iterator, [context])

Это произошло потому, что мы обратились не напрямую к библиотеке Underscore, а использовали встроенный метод коллекции each, который предоставил Backbone. Но разница небольшая — list перекочевал из аргументов на место псевдонима Underscore _. Поэтому, думаю, это не помешает вам с успехом пользоваться документацией Underscore, мысленно преобразовывая формат функций под Backbone. Однако, если вы привыкли обращаться напрямую к Underscore, то можете продолжать это делать. Ничего плохого в этом нет. Но мое личное мнение, которое никому не навязываю, в том, что встроенные методы Backbone более понятные даже для человека малознакомого с Underscore.

Метод sortBy имеет следующий вид.

list.sortBy(iterator, [context])

Этот метод возвращает отсортиртированный массив с экземплярами моделей в коллекции в порядке возрастания по тому значению, которое вернет итератор. Итератору передается в качестве первого параметра экземпляр модели.

Обратите внимание, что во-первых, этот метод ничего не делает с самой коллекцией, а во-вторых, возвращает не коллекцию, а обыкновенный массив с экземплярами моделей. Для того, чтобы отсортировать коллекцию из нашего примера по цене по возрастанию необходимо сделать следующее.

var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var sortedByPrice = advertisementsCollection.sortBy(function(model) {
  return model.get('price');
});

console.log(advertisementsCollection.toJSON()); // коллекция не изменилась

advertisementsCollection.reset(sortedByPrice); // коллекция обновлена целиком
console.log(advertisementsCollection.toJSON());
/* 
[
  {
    make: "Ниссан",
    model: "Альмера",
    odometer: 60000,
    price: 1400000,
    sold: false,
    year: 2010
  },
  {
    make: "Mazda",
    model: "Atenza",
    odometer: 70000,
    price: 1700000,
    sold: false,
    year: 2007
  },
  {
    make: "Тойота",
    model: "Королла",
    odometer: 40000,
    price: 2000000,
    sold: false,
    year: 2010
  },
]
*/

Представление коллекции

Итак, мы разобрались с тем, что такое коллекции, и немного посмотрели, как ими можно управлять. Однако, а с видами что? Тоже будем объединять их в коллекции? Несовсем. У коллекций создаются свои виды, которые очень похожи на виды у моделей. Главная разница в том, что виды привязываются к коллекциям с помощью свойства collection.

var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol'
});

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});

console.log(advertisementsCollectionView.el); // <ol></ol>

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

Теперь обратимся к функции render. Давайте подумаем, что она должна выполнять у вида коллекции. На самом деле основную работу мы уже сделали в виде модели. Теперь осталось все объединить. Наша render должна пробежаться по всей коллекции, для каждого экземпляра модели создать свой вид, запустив их метод render, а затем добавить их html код внутрь своего el.

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

<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      .car-sold {
        text-decoration: line-through;
      }
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <h1 id="header">Список ваших лотов</h1>
    
    <script type="text/template" id="ad-template">
      <input class="state-toggle" type="checkbox" <%= sold ? 'checked="checked"' : ''%>> Продается <strong><%= make %> <%= model %></strong> <%= year %> года выпуска с пробегом <%= odometer %> км. за <em><%= price %></em> иен <button class="edit-price">Изменить цену</button><button class="delete">Снять с торгов</button>
    </script>
    
    <script>
      // Здесь мы будем писать наш Javascript

var Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.model.on('change:price', this.render, this);
    this.model.on('destroy', this.remove, this);
    this.model.on('change:price', function() {
      alert('Новая цена: ' + this.model.get('price'));
    }, this);
    this.model.on('invalid', function(model, error) {
      alert(error);
    }, this);
  },
  events: {
    'click .state-toggle': 'toggleCarSoldState',
    'click .edit-price': 'editPrice',
    'click .delete': 'destroy'
  },
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    
    return this;
  },
  toggleCarSoldState: function() {
    this.model.toggleCarSoldState();
    
    this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
  },
  editPrice: function() {
    var newPrice = prompt('Введите пожалуйста новую цену', this.model.get('price'));
    
    if (newPrice === null) return;
    
    newPrice = $.trim(newPrice);
    if (newPrice === '') newPrice = NaN;
    
    this.model.set('price', +newPrice, {validate: true});
  },
  destroy: function() {
    this.model.destroy();
  },
  remove: function() {
    this.$el.remove();
  }
});
      
var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement
});

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
      
$('#header').after(advertisementsCollectionView.render().el);
      
    </script>
  </body>
</html>

JS Bin

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

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

Функция render вызывает для каждого экземпляра модели метод addOne, передавая ей текущий контекст вторым аргументом. Метод addOne создает экземпляр вида и увязывает его с текущим экземпляром модели в коллекции. А затем в конец элемента el вида коллекции добавляем el элемент вида модели, предварительно вызвав его метод render.

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

Практика: добавление нового объявления

Итак, на этот раз мы займемся практикой. Какого-то дополнительного теоритического материала в этой главе не будет, но от этого ее ценность нисколько не уменьшится. До этого момента мы имели дело только с экземплярами моделей и видов одного класса. Но ведь нам понадобится иметь дело с большим количеством разных классов. Иначе, зачем нам тогда заморачиваться с Backbone.js? Для рисования списков нам за глаза хватило бы и одного jQuery. Поэтому сейчас мы рассмотрим пример такого взаимодействия.

В данный момент в нашем примере список объявлений статичен. Да, мы можем удалять из него отдельные объявления, но не можем добавлять новые. Этим мы и займемся. Для этого нам понадобится сделать следующее. Создать форму, либо изначально прописав ее в html, либо создать по шаблону. Создать новый вид для формы в Backbone, так как мы имеем дело с пользовательским интерфейсом. Прописать в этом виде обработчик, который будет реагировать на событие submit формы. Сделать так, чтобы обработчик создавал новый экземпляр модели Advertisement, а затем добавлял его в коллекцию. Коллекция в свою очередь должна реагировать на добавление новой модели и отрисовывать в своем виде новый элемент списка.

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

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

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

<form id="add-new-ad">
  <input type="text" required placeholder="производитель" class="make-input">
  <input type="text" required placeholder="модель" class="model-input">
  <input type="text" required placeholder="год" class="year-input">
  <input type="text" required placeholder="цена" class="price-input">
  <input type="text" required placeholder="пробег" class="odometer-input">
  <input type="submit" value="добавить лот">
</form>

Далее нам нужен новый вид для этой формы, который будет ей управлять. Этот вид должен обрабатывать событие submit формы и создавать при этом новый экземпляр модели, а потом добавлять ее в коллекцию.

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = new Advertisement({
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    });
    
    advertisementsCollection.add(ad);
  }
});

Так как мы использовали уже существующую форму на странице, то чтобы к ней привязать наш новый вид, мы зададим el самостоятельно. Как вы помните, в таком случае, ему передается строка с селектором на существующий элемент. Далее, при помощи объекта events, мы повешали обработчик на элемент el, т.е. на форму, что в случае наступления события submit вызывается одноименная функция, которую определили, чуть ниже. В этой функции, первым делом предотвратили поведение формы по умолчанию, т.е. чтобы она не пыталась никуда отправить свои данные и не обновляла страницу. Затем мы создаем новый экземпляр модели, используя значения, которые пользователь внес в поля ввода, и добавляем его в существующий экземпляр коллекции, ссылку на который берем из замыкания.

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

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

Так как обработчик нам нужно повешать на саму коллекцию, то используем для этого функцию initialize вместо объекта events, который нужен для навешивания обработчиков на DOM элементы. У нас уже была функция, которая добавляла один элемент списка, поэтому дополнительно делать ничего не пришлось. При срабатывании события add в функцию-обработчик первым параметром передается добавляемый экземпляр коллекции, который подхватывается методом addOne.

Мы могли бы использовать вместо addOne метод render, и все бы работало. Однако, в таком случае список был бы перестроен целиком, что было бы излишним, если нам нужно всего-то добавить один элемент. Казалось бы мелочь, но если в списке много существующих элементов, то это снизит отзывчивость нашего интерфейса.

Теперь посмотрим код целиком и оценим результат в браузере.

<!DOCTYPE HTML>
<html>
  
  <head>
    <meta charset="utf-8" />
    <style>
      .car-sold {
        text-decoration: line-through;
      }
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <h1 id="header">Список ваших лотов</h1>
    <form id="add-new-ad">
      <input type="text" required placeholder="производитель" class="make-input">
      <input type="text" required placeholder="модель" class="model-input">
      <input type="text" required placeholder="год" class="year-input">
      <input type="text" required placeholder="цена" class="price-input">
      <input type="text" required placeholder="пробег" class="odometer-input">
      <input type="submit" value="добавить лот">
    </form>
    
    <script type="text/template" id="ad-template">
      <input class="state-toggle" type="checkbox" <%= sold ? 'checked="checked"' : ''%>> Продается <strong><%= make %> <%= model %></strong> <%= year %> года выпуска с пробегом <%= odometer %> км. за <em><%= price %></em> иен <button class="edit-price">Изменить цену</button><button class="delete">Снять с торгов</button>
    </script>
    
    <script>
      // Здесь мы будем писать наш Javascript

var Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

var AdvertisementView = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.model.on('change:price', this.render, this);
    this.model.on('destroy', this.remove, this);
    this.model.on('change:price', function() {
      alert('Новая цена: ' + this.model.get('price'));
    }, this);

    this.model.on('invalid', function(model, error) {
      alert(error);
    }, this);
  },
  events: {
    'click .state-toggle': 'toggleCarSoldState',
    'click .edit-price': 'editPrice',
    'click .delete': 'destroy'
  },
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    
    return this;
  },
  toggleCarSoldState: function() {
    this.model.toggleCarSoldState();
    
    this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
  },
  editPrice: function() {
    var newPrice = prompt('Введите пожалуйста новую цену', this.model.get('price'));
    
    if (newPrice === null) return;
    
    newPrice = $.trim(newPrice);
    if (newPrice === '') newPrice = NaN;
    
    this.model.set('price', +newPrice, {validate: true});
  },
  destroy: function() {
    this.model.destroy();
  },
  remove: function() {
    this.$el.remove();
  }
});
      
var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement
});

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = new Advertisement({
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    });
    
    advertisementsCollection.add(ad);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
      
var addNewAdvertisementView = new AddNewAdvertisementView;
      
$('body').append(advertisementsCollectionView.render().el);
      
    </script>
  </body>

</html>

JS Bin

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

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

Итак, здесь два негативных момента. Давайте еще раз глянем на исходный код.

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = new Advertisement({
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    });
    
    advertisementsCollection.add(ad);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
      
var addNewAdvertisementView = new AddNewAdvertisementView;

Сперва мы объявили класс вида, отвечающего за работу нашей формы. Важный момент здесь находится в строке 17. Здесь мы сослались на advertisementsCollection. Обратите внимание, что это не класс коллекции, а конкретный экземпляр коллекции, который хранит в себе наши объявления. Затем мы объявили этот самый экземпляр коллекции в строке 21. Далее идет объявление вида этой коллекции в строке 45, но для нас на данный момент это не интересно. А вот в строке 49 мы объявили уже экземпляр вида, отвечающего за работу формы. Так вот, чтобы эта конструкция работала, нужно чтобы экземпляр вида, объявленный в строке 49, мог видеть экземпляр коллекции, объявленный в строке 21. Когда пользователь нажмет на кнопку submit нашей формы, то экземпляр вида со строки 49 будет искать коллекцию с именем advertisementsCollection, чтобы добавить в нее новый экземпляр модели. И если он ее не найдет, то ничего не сработает. Таким образом, и экземпляр вида, и экземпляр коллекции должны быть в одной области видимости, а такое не всегда возможно.

Вторая проблема в том, что таким способом мы повысили интеграцию нашего приложения, да еще и таким неявным способом. Чтобы понять, что наш класс вида зависит от какого-то конкретного экземпляра коллекции, надо прочитать весь его код целиком. К тому же, если будет использоваться RequireJS, то все классы будут в отдельных файлах, и нужно будет еще догадаться, что его нужно прочитать. А потом придет другой разработчик, обзовет advertisementsCollection как-нибудь иначе, и вылезет ошибка, причем там, где ее не ожидали. Еще одна проблема появится, если нам захочется создать более одного вида формы. Например, это может понадобиться, если у нас будет несколько списков на странице, и у каждого должна быть своя форма для добавления новых элементов. Разные списки, значит разные экземпляры коллекций, а у нас все экземпляры видов будут привязаны только к одному конкретному, так как к нему привязан класс вида.

Возможным вариантом решения этих проблем было бы передавать ссылку на экземпляр коллекции уже экземпляру вида формы, а не классу (обратите внимание на строку 22).

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = new Advertisement({
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    });
    
    this.collection.add(ad);
  }
});

var addNewAdvertisementView = new AddNewAdvertisementView({
  collection: advertisementsCollection
});

И экземпляр вида, и экземпляр коллекции по прежнему должны находиться в одной области видимости, но по крайней мере сейчас это очевидно, так как при объявлении addNewAdvertisementView мы передаем ему ссылку на экземпляр коллекции advertisementsCollection. К тому же, если нам потребуется создать несколько экземпляров этого вида, то нам придется только поменять ссылки у их экземпляров на соответствующие экзмепляры коллекций.

Однако, и в этом решении не все гладко. Такая же зависимость у нас осталась теперь уже от класса модели Advertisement (в последнем листинге кода это строка 9, в которой создается новый экземпляр модели). И хотя зависимость класса от класса — это гораздо лучше, чем зависимость класса от экземпляра класса, попробуем от нее избавиться. На самом деле нам не нужен доступ к классу Advertisement, ведь он есть у коллекции, так как в описании ее класса мы передали ссылку на класс Advertisement в свойстве model, а потому мы можем передать ей сырые данные, которые она использует для создания экземпляра модели.

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = {
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    };
    
    this.collection.add(ad);
  }
});

var addNewAdvertisementView = new AddNewAdvertisementView({
  collection: advertisementsCollection
});

Теперь код смотрится гораздо лучше. Это был пример связи один к одному. Также этот способ подойдет для связей многие к одному. Но связи могут быть и один ко многим, и многие ко многим. Если использовать подобный подход, код быстро превратится в «кучу лапши». Нам придется хранить ссылки на все зависимые экземпляры классов, которые должны будут получать указания, а также помнить их методы, с помощью которых мы планируем ими управлять. И ладно, если это встроенные методы Backbone, такие как метод add, который мы использовали только что. Но ведь там могут быть и уникальные методы вроде getPriceInRUB. В итоге мы снизим читаемость кода и повысим при этом его интеграцию, осложнив внесение в него дальнейших изменений.

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

События

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

За систему событий Backbone.js отвечает объект Backbone.Events. Не надо путать его с объектом events, который мы использовали в видах для навешивания обработчиков. Тот объект был просто ассоциативным массивом, инструкцией, какой обработчик надо запустить при наступлении того или иного события на элементах DOM. Всю же работу выполнял Backbone.Events. Этот объект является миксином, который «вмешан» в следующие классы:

  • Backbone
  • Backbone.Model
  • Backbone.Collection
  • Backbone.Router
  • Backbone.History
  • Backbone.View

Это значит, что эти классы, а также их наследники, могут создавать события и слушать их. А как мы помним, все наши классы моделей, видов, коллекций мы создавали с помощью метода extend. Таким образом, все наши классы наследуют от базовых классов Backbone. На самом деле никто не запрещает нам наследовать от наших собственных классов с помощью того же метода extend, так как он корректно устанавливает цепочку прототипов. Таким образом классы могут наследоваться сколько угодно, но сейчас не об этом речь.

Управление обработчиками при помощи on/off

Навешивание обработчиков при помощи on мы уже не однократно применяли в коде нашего примера.

object.on(event, callback, [context])

Вы наверняка привыкли к нему, пользуясь jQuery. Однако, как мы уже рассматривали, у реализации on от Backbone есть небольшое отличие от своего собрата. Это третий необязательный параметр, передающий контекст функции обработчику.

В противоположность методу on, есть метод off, который снимает обработчики и имеет следующий вид.

object.off([event], [callback], [context])

Мне нечего добавить к официальной документации по этому методу. Просто сходите по ссылке и посмотрите его описание с примерами.

Управление обработчиками при помощи listenTo/stopListening

Метод listenTo имеет следующий синтаксис.

object.listenTo(other, event, callback)

В отличие от on, вызываемого у объекта, на котором будет создаваться событие, метод listenTo вызывается на объекте, который подписывается на события, и заставляет его слушать событие event на объекте other, вызываея при этом функцию callback. При этом this в обработчике всегда ссылается на слушающий объект.

Опять же, в противовес listenTo существует метод stopListening.

object.stopListening([other], [event], [callback])

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

Если уже существуют on/off, к которым привыкли все пользователи jQuery, то зачем придумывать что-то еще? Дело в том, что есть одна проблема с on/off. Если удалить модель одновременно с ее видом, то проблем никаких не возникнет. Но если удалить только вид, который слушает события модели, но обработчики при этом не были сняты с помощью off, то в модели останется ссылка на обработчики, а значит сборщик мусора Javascript не удалит вид из памяти. Такие виды называют видами-призраками (ghost view) или зомби. Как их называть, не суть важно и утечка памяти — не единственное следствие. Так как обработчики с видами остаются в памяти, то они продолжают работать, что может приводить к очень трудноуловимым ошибкам.

Наверное самым простым выходом из сложившейся ситуации является использование listenTo вместо on, потому что, когда нам понадобится удалить вид, достаточно просто воспользоваться встроенным методом remove, который автоматически вызовет на удаляемом виде метод stopListening, тем самым разорвав связи удаляемого вида с внешним кодом. Если мы уверены, что вид будет существовать столько, сколько существует само приложение, то использование on безопасно.

Одноразовый вызов обработчика

Методы once и listenToOnce ведут себя точно также как методы on и listenTo соответственно с той лишь разницей, что после первого срабатывания события обработчики, привязанные с их помощью, удаляются.

Собственные события (custom events)

Если вы активно пользуетесь ООП, а я надеюсь, что так и есть, то вы наверняка знакомы с собственными событиями. Это очень удобный способ связи между объектами, потому что позволяет объекту-отправителю события ничего не знать о получателе. К тому же этих получателей может быть неограниченное количество. Все что от них требуется — это подписаться на события объекта-отправителя и реагировать при его наступлении.

Обычно для создания собственного события используется метод jQuery triggerHandler. В Backbone есть своя реализация метода для создания собственных событий, которая немного отличается от реализации в jQuery.

Для создания собственных событий используется метод trigger.

object.trigger(event, [*args])

Этот метод генерирует событие event, которое написано в виде строки (или группы событий, если они написаны через пробел). При этом все последующие аргументы за event будут переданы в обработчик события.

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

var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement,
  initialize: function() {
    this.on('addNewModel', this.add, this);
  }
});

var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = {
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    };
    
    this.collection.trigger('addNewModel', ad);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
   
var addNewAdvertisementView = new AddNewAdvertisementView({
  collection: advertisementsCollection
});

Обратите внимание на строку номер 40. В ней мы вместо того, чтобы сразу вызвать у коллекции метод add, создали собственное событие addNewModel. Также мы повесили обработчик на коллекцию на строке 4, который сработает при появлении данного события на коллекции и вызовет у нее метод add, которому также передастся наш ассоциативный массив с новыми данными для создания нового экземпляра модели.

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

Также обратите внимание, что экземпляру вида addNewAdvertisementView, отвечающему за форму, по прежнему надо быть в одной области видимости с экземпляром коллекции. Хоть мы и создаем событие, т.е. объекту-отправителю нет необходимости иметь доступ к объекту-получателю, в данном примере такой доступ оказался необходим, ведь мы создавали событие прямо на объекте-получателе. Однако, нам никто не мешает создавать некое событие на каком-то третьем объекте, к которому есть доступ и у объекта-отправителя, и у объекта-получателя, а друг о друге они могут и не знать вовсе. Для этого нам нужно создать такой объект, который будет выступать связующей шиной для объектов.

Для этого можно создать новый объект путем расширения Backbone.Events. Его обычно называют vent (сокращение от event).

var vent = {};
_.extend(vent, Backbone.Events);

Теперь на этом объекте можно создавать свои события и на нем же их слушать.

vent.on('hello', function(person) {
  alert('Привет, ' + person);
});

vent.trigger('hello', 'Пятачок'); // "Привет, Пятачок"

Переделаем наш пример с использованием диспетчера vent.

var vent = {};
_.extend(vent, Backbone.Events);

var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement,
  initialize: function() {
    vent.on('addNewModel', this.add, this);
  }
});
      
var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = {
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    };
    
    vent.trigger('addNewModel', ad);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
   
var addNewAdvertisementView = new AddNewAdvertisementView;

На 43 строке мы создаем событие addNewModel на диспетчере vent, а на 7 строке его ловим уже внутри класса коллекции, создавая новый экземпляр модели. Теперь, несмотря на то, что мы добавили новый промежуточный шаг в виде вызова события, мы разорвали связь между видом, отвечающим за форму, и экземпляром коллекции. Обратите внимание, что в последней строчке мы просто объявляем вид addNewAdvertisementView. Теперь эти объекты между собой напрямую ничего не связывает. Они могут быть в любых частях кода, главное чтобы им обоим был виден объект vent. По этой причине vent лучше располагать поближе к глобальному уровню.

Нас никто не ограничивает в количестве подобных объектов. Если приложение большое, то возможно будет иметь смысл создать более одного такого vent. Еще одна тонкость, граничащая с хаком, состоит в том, что на роль диспетчера подойдет любой объект, который наследует от Backbone.Events. Например, сам объект Backbone. Его не нужно создавать, так как он уже есть, и он доступен из любой точки программы. В таком случае предыдущий пример, выглядел бы вот так.

var AdvertisementsCollection = Backbone.Collection.extend({
  model: Advertisement,
  initialize: function() {
    Backbone.on('addNewModel', this.add, this);
  }
});
      
var AdvertisementsCollectionView = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdvertisementView({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

var AddNewAdvertisementView = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = {
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    };
    
    Backbone.trigger('addNewModel', ad);
  }
});
      
var advertisementsCollection = new AdvertisementsCollection([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdvertisementsCollectionView({
  collection: advertisementsCollection
});
   
var addNewAdvertisementView = new AddNewAdvertisementView;

Если вы используете однотипные события, то имеет смысл использовать области имен. Рекомендованным разделителем областей имен в Backbone является двоеточие.

var vent = {};
_.extend(vent, Backbone.Events);

vent.on('greet:respectfully', function(person) {
  alert('Здравствуйте, ' + person);
});

vent.on('greet:cheerfully', function(person) {
  alert('Привет, ' + person);
});

vent.trigger('greet:respectfully', 'Сова'); // "Здравствуйте, Сова"
vent.trigger('greet:cheerfully', 'Пятачок'); // "Привет, Пятачок"
vent.trigger('greet', 'Винни-Пух'); // ничего не произойдет

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

Области имен полезны не только в названиях событий, но и в названиях всех создаваемых объектов в Backbone. В следующей главе мы посмотрим один из примеров использования областей имен.

Организация кода: области имен

Любой программист, обеспокоенный качеством своего кода, знает, насколько важно давать значимые имена переменным и функциям. В таком случае код говорит сам за себя, что позволяет обойтись без большого количества коментариев, вставляя их лишь там, где они действительно нужны. В нашем примере я старался следовать этому правилу. Однако, вы наверняка заметили, что эти имена становятся слишком похожи друг на друга. Во многих присутствует слово collection и view, в первую очередь потому что они являются коллекциями, видами или видами коллекций. С одной стороны, это хорошо, так как лучше следовать какой-то договоренности, чем брать имена переменных с потолка. Это вносит порядок и единообразие. С другой, все эти названия сливаются между собой. Быстро глянув на переменную, трудно, не осмыслив ее название, сказать модель это, вид или коллекция. Также трудно различать их между собой. С увеличением классов в приложении эта проблема будет только усиливаться.

Хорошим решением может стать использование областей имен. Для этого отлично подходят объекты как ассоциативные массивы. Здесь нет какого-то единственного верного решения, поэтому мы рассмотрим один пример, а вы можете использовать его или что-то подобное. Главное выберите какой-то один вариант и следуйте ему. Итак, вот заготовка.

var AdList = {
  Models: {},
  Views: {},
  Collections: {}
};

Все модели будем записывать в свойства объекта AdList.Models, виды — в свойства AdList.Views и коллекции — в свойства AdList.Collections. Общее название приложения AdList конечно может быть каким угодно, лишь бы отражало его суть и было покороче, так как вам его часто придется писать. Названия моделей и видов моделей всегда в единственном числе, а названия коллекций и их видов наоборот всегда во множественном. Теперь у нас отпала необходимость хранить в имени переменной принадлежность ко внутренним классам Backbone, и даже беглого взгляда на переменную достаточно, чтобы понять, чем она является.

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

<!DOCTYPE HTML>
<html>
  
  <head>
    <meta charset="utf-8" />
    <style>
      .car-sold {
        text-decoration: line-through;
      }
    </style>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.0/backbone-min.js"></script>
  </head>
  
  <body>
    <!-- Здесь будут располагаться DOM элементы -->
    <h1 id="header">Список ваших лотов</h1>
    <form id="add-new-ad">
      <input type="text" required placeholder="производитель" class="make-input">
      <input type="text" required placeholder="модель" class="model-input">
      <input type="text" required placeholder="год" class="year-input">
      <input type="text" required placeholder="цена" class="price-input">
      <input type="text" required placeholder="пробег" class="odometer-input">
      <input type="submit" value="добавить лот">
    </form>
    
    <script type="text/template" id="ad-template">
      <input class="state-toggle" type="checkbox" <%= sold ? 'checked="checked"' : ''%>> Продается <strong><%= make %> <%= model %></strong> <%= year %> года выпуска с пробегом <%= odometer %> км. за <em><%= price %></em> иен <button class="edit-price">Изменить цену</button><button class="delete">Снять с торгов</button>
    </script>
    
    <script>
      // Здесь мы будем писать наш Javascript
(function() {

var AdList = {
  Models: {},
  Views: {},
  Collections: {}
};
  
var vent = {};
_.extend(vent, Backbone.Events);

AdList.Models.Advertisement = Backbone.Model.extend({
  defaults: {
    sold: false
  },
  validate: function(attrs) {
    if (attrs.price <= 0) {
      return 'Цена должна быть больше 0';
    }
    
    if (isNaN(attrs.price)) {
      return 'Цена должна быть числом';
    }
  },
  toggleCarSoldState: function() {
    this.get('sold') ? this.set('sold', false) : this.set('sold', true);
  }
});

AdList.Views.Advertisement = Backbone.View.extend({
  tagName: 'li',
  initialize: function() {
    this.model.on('change:price', this.render, this);
    this.model.on('destroy', this.remove, this);
    this.model.on('change:price', function() {
      alert('Новая цена: ' + this.model.get('price'));
    }, this);

    this.model.on('invalid', function(model, error) {
      alert(error);
    }, this);
  },
  events: {
    'click .state-toggle': 'toggleCarSoldState',
    'click .edit-price': 'editPrice',
    'click .delete': 'destroy'
  },
  template: _.template($('#ad-template').html()),
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    
    return this;
  },
  toggleCarSoldState: function() {
    this.model.toggleCarSoldState();
    
    this.model.get('sold') ? this.$el.addClass('car-sold') : this.$el.removeClass('car-sold');
  },
  editPrice: function() {
    var newPrice = prompt('Введите пожалуйста новую цену', this.model.get('price'));
    
    if (newPrice === null) return;
    
    newPrice = $.trim(newPrice);
    if (newPrice === '') newPrice = NaN;
    
    this.model.set('price', +newPrice, {validate: true});
  },
  destroy: function() {
    this.model.destroy();
  },
  remove: function() {
    this.$el.remove();
  }
});

AdList.Collections.Advertisements = Backbone.Collection.extend({
  model: AdList.Models.Advertisement,
  initialize: function() {
    vent.on('addNewModel', this.add, this);
  }
});
      
AdList.Views.Advertisements = Backbone.View.extend({
  tagName: 'ol',
  initialize: function() {
    this.collection.on('add', this.addOne, this);
  },
  render: function() {
    this.collection.each(this.addOne, this);
    return this;
  },
  addOne: function(ad) {
    var adView = new AdList.Views.Advertisement({ model: ad });
    
    this.$el.append(adView.render().el);
  }
});

AdList.Views.AddNewAdvertisement = Backbone.View.extend({
  el: '#add-new-ad',
  events: {
    'submit': 'submit'
  },
  submit: function(e) {
    e.preventDefault();
    
    var ad = {
      make: this.$el.find('.make-input').val(),
      model: this.$el.find('.model-input').val(),
      year: this.$el.find('.year-input').val(),
      price: this.$el.find('.price-input').val(),
      odometer: this.$el.find('.odometer-input').val(),
    };
    
    vent.trigger('addNewModel', ad);
  }
});
      
var advertisementsCollection = new AdList.Collections.Advertisements([
  {
    make: 'Mazda',
    model: 'Atenza',
    year: 2007,
    price: 1700000,
    odometer: 70000
  },
  {
    make: 'Тойота',
    model: 'Королла',
    year: 2010,
    price: 2000000,
    odometer: 40000
  },
  {
    make: 'Ниссан',
    model: 'Альмера',
    year: 2010,
    price: 1400000,
    odometer: 60000
  }
]);

var advertisementsCollectionView = new AdList.Views.Advertisements({
  collection: advertisementsCollection
});
   
var addNewAdvertisementView = new AdList.Views.AddNewAdvertisement;

$('body').append(advertisementsCollectionView.render().el);

})(); 
    </script>
  </body>

</html>

Роутеры (Routers) и История (History)

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

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

Роутер (Router), как видно из названия, занимается маршрутизацией. В этот объект записываются все возможные паттерны (роуты) для адресной строки, а также соответствующая каждому паттерну функция, которая будет вызвана, в случае если текущая адресная строка будет ему удовлетворять.

История (History) же запускает отслеживание изменений. Если выражаться по-простому, то роутер — это план действий, а история — большая красная кнопка, которая запускает его исполнение (у объекта Backbone.History есть только один встроенный метод start). Backbone поддерживает отражение текущего состояния в адресной строке как с помощью хэшей (#inbox/id107), так и с помощью History API (inbox/id107) в тех браузерах, которые это поддерживают, деградируя до использования хешей в старых браузерах.

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

Роутер создается путем расширения базового класса Backbone.Router. Все маршруты записываются в виде ассоциативного массива в свойство routes. В паре ключ-значение в ключ записывается паттерн для адресной строки, а в значение — строка с названием функции, которая будет вызвана в случае совпадения. Сами вызываемые функции записываются в свойства будущего класса роутера.

var Router = Backbone.Router.extend({
  routes: {
    'show': 'show'
  },
  show: function() {
    console.log('вызвана функция show');
  }
});

var router = new Router;
Backbone.history.start();

Это очень простой пример применения маршрутизации в Backbone. Сперва мы описали класс нашего роутера, затем в строке 10 создали его экземпляр, и в последней строке запустили глобальный роутер. Теперь, если добавить к адресной строке #show, то будет вызвана функция show, и в консоли можно будет увидеть надпись "вызвана функция show".

Как вы заметили, этот пример использует хэш-фрагмент, если бы мы хотели использовать настоящий URL (в таком случае к адресной строке бы прибавилась подстрока ‘/show’), то надо было бы передать в метод Backbone.history.start объект {pushState: true}.

Backbone.history.start({pushState: true});

Однако, использование pushState потребовало бы от серверной части быть готовой отдавать информацию по этому адресу. Так как серверной части мы еще не касались, то ограничимся использованием хеш-фрагментов для примеров в этой главе.

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

Лучше всего понять, как это работает, можно на следующем примере.

var Router = Backbone.Router.extend({
  routes: {
    '': 'index',
    'search/:query': 'search', // пример URL: #search/events
    'show/s:section/p:page': 'show',  // пример URL: #show/s10/p4
    'file/*path': 'download', // пример URL: #file/my_files/books/backbone-book.pdf
    'manual/:section(/:subsection)': 'manual',  // пример URL: #manual/quick-install или #manual/faq/first-install
    '*other': 'notFound' // пример URL: #whatever-blah/blah или #show/s14
  },
  index: function() {
    console.log('эта функция вызывается, если к URL ничего не прибавлено, так как паттерн представлен пустой строкой');
  },
  search: function(query) {
    console.log('вызвана функция search с запросом: ' + query);
  },
  show: function(section, page) {
    console.log('вызвана функция show для показа section: ' + section + ' на странице page: ' + page);
  },
  download: function(path) {
    console.log('вызвана функция download для доступа к файлу, находящемуся по адресу: ' + path);
  },
  manual: function(section, subsection) {
    if (subsection) {
      console.log('вызвана функция manual для показа section: ' + section + ' и subsection: ' + subsection);
      return;
    }

    console.log('вызвана функция manual для показа section: ' + section);
  },
  notFound: function(other) {
    console.log('вы обратились по несуществующему адресу: ' + other);
  }
});

/*
При добавлении в адресную строку следующих подстрок в консоли появятся соответствующие им уведомления

'': 'эта функция вызывается, если к URL ничего не прибавлено, так как паттерн представлен пустой строкой'
'#search/events': 'вызвана функция search с запросом: events'
'#show/s10/p4': 'вызвана функция show для показа section: 10 на странице page: 4'
'#file/my_files/books/backbone-book.pdf': 'вызвана функция download для доступа к файлу, находящемуся по адресу: my_files/books/backbone-book.pdf'
'#manual/quick-install': 'вызвана функция manual для показа section: quick-install'
'#manual/faq/first-install': 'вызвана функция manual для показа section: faq и subsection: first-install'
'#whatever-blah/blah': 'вы обратились по несуществующему адресу: whatever-blah/blah'
'#show/s14': 'вы обратились по несуществующему адресу: show/s14'
*/

Обратите внимание на последний пример. Так как параметр :page не является необязательным, то была вызвана функция notFound, а не show с параметром page, равным undefined, как могло бы показаться.

Также немаловажным является тот факт, что, когда любой из роутов находит соответствие, на экземпляре роутера создается событие route.

Роутер — отличное место для создания собственных событий. Т.е. вместо того, чтобы заставлять функции в роутере самостоятельно создавать модели, виды и коллекции, лучше ограничиться лишь созданием собственных событий и передать эту работу тем объектам, которые будут ожидать наступления этих событий. Это сделает код более понятным и организованным.

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

Для обновления адресной строки используется метод navigate. По умолчанию он заменяет добавляемую часть адресной строки на то, что было ему передано в качестве аргумента. При этом, если добавочная часть адресной строки окажется равной какому либо роуту, то вызова соответствующей ему функции не будет. Одновременно с этим будет создана запись в истории браузера, чтобы можно было вернуть адресную строку в исходное состояние с помощью кнопки браузера «назад». Если при этом все-таки требуется вызывать корреспондирующую роуту функцию, то дополнительным параметром в метод navigate необходимо передать объект options с {trigger: true}. А если передать в этом объекте {replace: true}, то обновление URL произойдет без создания записи в истории браузера.

var router = new Router;

/* во всех 3-х случаях произойдет обновление добавочной части URL, однако ... */
router.navigate('show/s14/p1'); // вызова функции соответсвующей данному роуту не произойдет, и будет создана запись в истории браузера
router.navigate('show/s15/p1', {trigger: true}); // будет вызвана функция соответсвующая данному роуту, и будет создана запись в истории браузера
router.navigate('show/s16/p1', {trigger: true, replace: true}); // будет вызвана функция соответсвующая данному роуту, но запись в истории браузера создана не будет 

Использовать {trigger: true} не рекомендуется, кроме случаев, когда программист точно понимает, зачем ему это нужно. Дело в том, что задача роутеров — быть проводником к определенному состоянию приложения из вне, т.е. если пользователь перешел по ссылке с какого-то другого сайта или из закладок браузера. Использовать же роутеры для перехода между одним состоянием к другому, во время когда приложение уже загружено, неправильно. И причина тут не в идеологии, а в том, что код, который необходимо выполнить, чтобы добраться до определенного состояния приложения «с нуля», как правило, отличается от кода, который необходим для перехода из какого-то промежуточного состояния до искомого конечного.

Если приложение запускается в первый раз, то, как правило, необходима какая-то подготовительная работа — создание экземпляров коллекций, создание и загрузка первоначального вида и т.д. После этого начинается уже какой-то общий код, который будет запускаться и при первоначальной загрузке приложения, и при переходе к этому состоянию из какого-то другого существующего состояния. Если переход от одного состояния к другому будет происходить при помощи метода navigate с {trigger: true}, то подготовительная работа также будет выполнена. И эта лишняя работа будет делаться каждый раз при переходе к этому состоянию. В результате получим утечки памяти на повторное создание лишних объектов, снижение производительности и ошибки разной степени уловимости. Конечно повторного выполнения кода можно избежать, если обвешать функции роутера проверками на все случаи жизни, но такой код уже начинает пахнуть.

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

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