Добрый день, друзья! Недавно я побывал на конференции dotnetconf. Было много интересного, в том числе доклады по Single Page Application (SPA). Сначала Константин Зотов рассказал нам о backbone на примере простого приложения todo. После, Евгений Абросимов поведал нам о том, как эффективно строить SPA, какие есть подводные камни, и за что можно уволить фронтэнд разработчика . Вообще, хоть я и не фронтенд девелопер, у меня уже был небольшой опыт работы с библиотекой backbone и мысль о ней написать в блоге. Но эти два парня вдохновили меня таки это сделать.
Итак, далее речь пойдет о основах библиотеки, и также сделаем небольшой пример. Если Вы хотите ознакомиться с данной библиотекой, или есть желание научиться писать SPA, то эта статья будет Вам полезна. Поехали!
Начнем с описания приложения, которое будем создавать. Я хочу сделать некое подобие CMS (даже громко сказано), то есть заложить возможность создавать HTML страницы, редактировать их содержимое, просматривать (используя хеши), удалять страницы, а также, чтобы вся модель могла сохраняться в локальном хранилище (localstorage). Для начала, мы рассмотрим немного теории, а затем, понемногу перейдем к практике.
Если Вы, также как и я, использовали javascript в основном только для того, чтобы работать с какими то событиями или манипулировать DOM (что, в принципе, достаточно для самых простейших сценариев), то Вы должны понять, почему я начну издалека. Первым делом я расскажу о классах в javascript.
Классы в Javascript
Не смотря на то, что в рунете об этом довольно много информации, часто начинающие разработчики и не знают о том, что в javascript присутствует поддержка ООП. Я постараюсь быть предельно кратким и расскажу только тот материал, понимание которого требуется для понимания всей статьи. Если с ходу будет непонятно, то я положу несколько ссылок с дополнительными материалами в конце статьи.
В общем, в javascript действительно есть классы. Для того, чтобы создать экземпляр класса, мы можем воспользоваться обычной функцией. Вот пример:
1: var SomeClass = function(){
2: this.id = 0;
3: this.name = 'name of instance';
4: };
5:
6: var instance = new SomeClass();
7: console.log(instance);
Здесь функция SomeClass является функцией – конструктором. Когда мы вызываем её с ключевым словом new, то контекст функции (то есть переменная this) становится пустым объектом, свойства которого мы заполняем в нашей функции и этот объект является результатом вызова конструктора, потому сохраняется в переменную instance. При выполнении этого кода, мы увидим следующее:
Выглядит просто, верно? Но у нас не всегда есть возможность вносить какие либо изменения в логику класса. Зачастую, нам надо создать экземпляр класса, но с какими то нашими специфичными полями или методами. В таких случаях обычно говорят о наследовании, но я покажу другой вариант. Мы должны понимать, что функция-конструктор такой же объект javascript и мы вольны добавлять в него любые дополнительные поля и методы. Давайте добавим в него метод который будет возвращать нам новый конструктор, при этом инициализируя какие то наши поля.
1: var SomeClass = function(){
2: this.id = 0;
3: this.name = 'name of instance';
4: };
5:
6: SomeClass.extend = function(options){
7: return function(){
8: var elt = new SomeClass();
9: for(var i in options){
10: elt[i] = options[i];
11: }
12: return elt;
13: }
14: };
15:
16: var instance = new SomeClass();
17:
18: var SomeClass2 = SomeClass.extend({desc:'description', log:function(){console.log(this);}});
19: var instance2 = new SomeClass2();
20:
21: instance2.log();
В результате выполнения кода получим следующее
Похожий механизм используется в backbone. Но давайте обо всем по порядку.
Backbone. Начало.
Сама библиотека изначально подразумевала использование паттерна MVC (Model-View-Controller) в приложении. Однако, как мне пояснили на конференции, в последующих версиях контроллеры были исключены из библиотеки, вместо них стали использовать роутеры. Аббревиатура паттерна сама не изменилась, но изменился смысл MVC (Model-View-Collection), что подчеркивает важность коллекций в библиотеке. От Жени Абросимова я узнал и другую версию этого паттерна MVW (Model-View-Whatever), это значит, что библиотека более не затачивается на конкретную архитектуру приложения и может органично работать с другими инструментами. Также библиотека имеет несколько зависимостей – это библиотеки underscore и jquery (вместо jquery можно использовать библиотеку zepto).
Немного теории + много практики.
Как Вы уже догадались, основные действующие лица в backbone – это модель, представление и коллекция. Начнем с модели.
Модель, как и в других паттернах (таких, как MVC, MVP, MVVM, etc) – представляет собой хранилище единицы данных, с которыми планируется работать. В списке товаров это будет товар, в информации по заказам (или заказу) – заказ. Для моделей в backbone есть специальный класс – Backbone.Model. Этот объект, как нетрудно догадаться, является просто функцией-конструктором
1: var Model = Backbone.Model = function(attributes, options) {
2: var defaults;
3: var attrs = attributes || {};
4: options || (options = {});
5: ..........
6: };
Эта функция принимает в качестве параметров пару аргументов – атрибуты нашей модели и объект с параметрами. Как видно, оба аргумента являются необязательными. Однако, напрямую нам его использовать нет смысла – ведь нам наверняка понадобятся различные методы. Тут нам пригодится показанный выше приём, где мы расширяли конструктор (отмечу, что в данном случае extend – метод библиотеки backbone. Наш метод extend я написал только для общего понимания работы метода библиотеки). Поскольку мы делаем CMS, давайте определим модель для страницы
1: // Конструктор для страницы
2: var PageItem = Backbone.Model.extend({
3:
4: // Значения по умолчанию
5: defaults:{
6: title:'default page',
7: hash:'index',
8: content:'default content'
9: }
10: });
Выглядит довольно просто, верно? Всё, что нам нужно знать о странице – это заголовок, контент страницы и хеш, по которому будем эту страницу отображать. Теперь об отображении. Я предполагаю, что наше приложение будет выглядеть как то так:
Тут мы видим сверху меню навигации для страниц, далее поля ввода для создания новой страницы, и ниже – заголовок страницы, контент страницы и кнопку для удаления страницы (заголовок и контент редактируемые). Очевидно, для того, чтобы отобразить страницу, нам понадобятся 2 представления – это представление в меню навигации и представление для редактирования.
Начнем с представления в меню навигации. Мне нужно, чтобы страница в результате рендерилась в элемент несортированного списка, как то так:
Сам элемент LI будет выступать контейнером для представления, а рендер будет происходить в ссылку. Для этого я в HTML разметке определил шаблон, по которому будет происходить формирование представления. Работа с шаблонами – это функционал библиотеки underscore.
1: <script type="text/template" id="page-nav-template">
2: <a href="#pages/<%=hash%>"><%=title%></a>
3: </script>
Код шаблона тривиален, потому перейдем к коду представления. Отмечу, что ниже приведен код конструктора представления, экземпляры мы ещё не создавали.
1: // Конструктор представления для рендеринга страницы в области навигации
2: var PageViewNav = Backbone.View.extend({
3: // Тег контейнера, в котором будет рендериться все представление
4: tagName:'li',
5:
6: // Инициализация.
7: initialize: function() {
8:
9: // Перерисовываем если модель изменяется
10: this.listenTo(this.model, 'change', this.render);
11:
12: // Удаляем представление, если модель удалена
13: this.listenTo(this.model, 'destroy', this.remove);
14: },
15:
16: // Мы заводим собственное поле template - своеобразный кеш для переменной.
17: // То есть мы могли бы и при рендеринге каждый раз получать этот темплейт, но чтобы
18: // не быть расточительными, сохраним эту переменную сразу
19: template: _.template($('#page-nav-template').text()),
20:
21: // Метод занимается отображением нашего представления.
22: // По сути мы просто применяем нашу модель к шаблону (темплейту)
23: render:function(){
24: this.$el.html(this.template(this.model.attributes));
25: return this;
26: }
27: });
Теперь дело за вторым представлением модели – тем, где можно эту модель изменять/удалять. Отличия этого представления в том, что мы выполняем некоторые манипуляции с моделью, в зависимости от действий пользователя. Дело происходит так: представление генерирует разметку и в этой разметке подписывается на события элементов. Как только разметка будет помещена в DOM, пользователь её увидит и сможет с ней работать. При определенных действиях будут вызываться методы представления (при помощи событий), и в этих методах мы будет моделью манипулировать. При изменении модели, сама модель будет генерировать различные события. Поскольку оба представления у нас подписаны на изменения модели, они будут реагировать. Например, если мы изменим заголовок страницы, то должны обновиться все представления, которые отображают эту страницу. Дальше, в принципе, всё аналогично предыдущему примеру. Вот шаблон для отрисовки:
1: <script type="text/template" id="page-view-template">
2: <h3>
3: <div class="title" contenteditable="true"><%=title%></div>
4: </h3>
5: <p>
6: <div role="form">
7: <div class="form-group">
8: <div class="view" contenteditable="true"><%=content%></div>
9: </div>
10: <button type="button" class="btn btn-default" id="del">Delete</button>
11: </div>
12: </p>
13: </script>
Как видно, я использую атрибут contenteditable для возможности редактирования. В принципе, это не обязательно, можно использовать любые средства. Далее, код представления:
1: // Конструктор представления для рендеринга контента страницы
2: var PageView = Backbone.View.extend({
3:
4: // Тег контейнера, в котором будет рендериться все представление
5: tagName:'div',
6:
7: // Привязка событий
8: events: {
9: 'blur div.view': 'save',
10: 'blur div.title': 'save',
11: 'click button#del': 'delete'
12: },
13:
14: // Инициализация.
15: initialize: function() {
16:
17: // Перерисовываем если модель изменяется
18: this.listenTo(this.model, 'change', this.render);
19:
20: // Удаляем представление, если модель удалена
21: this.listenTo(this.model, 'destroy', this.remove);
22: },
23:
24: // Мы заводим собственное поле template - своеобразный кеш для переменной.
25: // То есть мы могли бы и при рендеринге каждый раз получать этот темплейт, но чтобы
26: // не быть расточительными, сохраним эту переменную сразу
27: template: _.template($('#page-view-template').text()),
28:
29: // Метод занимается отображением нашего представления.
30: // По сути мы просто применяем нашу модель к шаблону (темплейту)
31: render:function(){
32: document.title = this.model.get('title');
33: this.$el.html(this.template(this.model.attributes));
34: return this;
35: },
36:
37: // Метод сохраняет состояние нашей модели, вызывает событие модели change.
38: // Срабатывает по событию.
39: save:function(){
40: this.model.save({content:$('div.view').html(), title:$('div.title').text()});
41: },
42:
43: // Метод для удаления модели. Срабатывает по событияю.
44: delete:function(){
45: this.model.destroy();
46: }
47: });
Отлично, теперь у нас есть классы для модели и для представлений. Но модель у нас будет не одна – у нас будет некое множество моделей, и их надо где то хранить. Для этого (и не только), существуют так называемые коллекции. Коллекции не только хранят модели, они могут хранить их в определенном порядке, они следят за изменениями моделей, они могут синхронизировать данные моделей с сервером по протоколу REST или хранить их каким другим способом. Нас интересует хранение моделей в локальном хранилище, потому я использую такое дополнение к библиотеке, как Backbone.localStorage
1: // Конструктор коллекции страниц
2: var PageCollection = Backbone.Collection.extend({
3: // Указываем, с каким типом модедей будем работать
4: model:PageItem,
5:
6: // Устанавливаем хранилище для элементов страниц - локальное хранилище
7: localStorage: new Backbone.LocalStorage("pages")
8: });
Этого кода достаточно, чтобы экземпляры коллекций при сохранении изменений или загрузке данных использовали локальное хранилище. Итак, у нас есть классы модели, коллекции и представлений. Но чего то не хватает. Чего то, что будет организовывать их совместную работу. К тому же, у нас есть ещё неработающий функционал с добавлением моделей в коллекцию. Нам нужен тот злобный гений, который будет сидеть выше всех и дергать наш код за ниточки. Поскольку речь идет о работе с разметкой и реагировании на события, очевидно, что речь идет о неком представлении.
Вот HTML, с которым нам придется работать:
1: <div class="app">
2: <div class="container">
3: <div class="starter-template">
4:
5: <div role="form" class="form-inline">
6: <div class="form-group">
7: <label class="sr-only" for="title">Title</label>
8: <input type="text" class="form-control" id="title" placeholder="Enter title">
9: </div>
10:
11: <div class="form-group">
12: <label class="sr-only" for="hash">Hash</label>
13: <input type="text" class="form-control" id="hash" placeholder="Enter hash">
14: </div>
15:
16: <div class="form-group">
17: <label class="sr-only" for="content">Content</label>
18: <input type="text" class="form-control" id="content" placeholder="Enter content">
19: </div>
20:
21: <button type="button" class="btn btn-default" id="add">Add</button>
22: </div>
23:
24:
25: </div>
26: </div>
27:
28: <div class="container">
29: <div class="starter-template">
30: <div class="row">
31: <div class="content"></div>
32: </div>
33: </div>
34: </div>
35: </div>
Конечно, довольно путанно. Но я отмечу 3 вещи: поля для ввода данных с кнопкой и <div class="content"></div>. Поля и кнопка предназначены для добавления страниц, а указанный див – в него будем рендерить представление модели для редактирования. Вот код этого представления:
1: // Коллекция страниц. Инициализируем её тут, так как далее в определениях
2: // предсталения и роутере будем эту коллекцию использовать
3: var pages = new PageCollection();
4:
5: // Конструктор предсталения нашего приложения
6: var AppView = Backbone.View.extend({
7: el: $('.app'),
8: events: {
9: // Привязываемся к кнопке - прописываем метод для добавления страницы
10: 'click button#add': 'add'
11: },
12: initialize: function () {
13:
14: // Привязываемся к событияю добавления страницы в коллекцию страниц
15: pages.bind('add', this.addItem);
16:
17: // Загружаем сохраненные ранее страницы из локального хранилища
18: pages.fetch();
19: },
20:
21: // Событие добаления элемента в коллекцию.
22: // Тут нужно создать представление для модели, выполнить рендер и
23: // сохранить результат в DOM
24: addItem: function (model) {
25: var view = new PageViewNav({model: model});
26: view.render();
27: $('.nav.navbar-nav').append(view.el);
28: },
29:
30: // Добавление новой страницы. По сути все просто -
31: // получаем введеные пользователем значения полей,
32: // добавляем новую модель в коллекцию и очищаем поля
33: add: function () {
34: pages.create({
35: content: $('#content').val(),
36: title: $('#title').val(),
37: hash: $('#hash').val()
38: });
39: $('#content').val('');
40: $('#title').val('');
41: $('#hash').val('');
42: }
43: });
Тут все ясно из комментариев. Отмечу ещё раз, что перед кодом представления мы инициализировали экземпляр коллекции, так как в самом представлении нам нужно будет с ней взаимодействовать.
Итак, осталось совсем немного. Для начала, надо как то работать с хешем. Если помните, в навигационном меню мы отображали ссылку на страницу в виде хеша. Для того, чтобы перехватывать переход по определенному хешу, нужно воспользоваться таким механизмом, как роутеры. Ниже определение класса нашего роутера
1: // Конструктор роутера
2: var WorkspaceRouter = Backbone.Router.extend({
3:
4: routes: {
5: // маршрут для перехвата выбранной страницы
6: "pages/:hash": "page", // #pages/hash
7:
8: // пытаемся отловить все другие маршруты, чтобы
9: // если не удастся сопоставить маршут странице - просто
10: // затереть место, где выводится текущая страница.
11: '':"default",
12: '*query':"default"
13: },
14:
15: // Если по хешу мы можем найти нашу страницу - то мы должны её отрендерить,
16: // иначе просто показать пустое место
17: page: function(hash) {
18: var page = pages.findWhere({hash:hash});
19: if (page){
20: var view = new PageView({model: page});
21: view.render();
22: $('.content').html(view.el);
23: }
24: else
25: $('.content').empty();
26: },
27:
28: // затираем на месте страницы все контролы
29: default:function(){
30: $('.content').empty();
31: }
32: });
Комментарии в коде довольно подробные. Для законченности приложения, осталось сделать всего пару вещей. Во первых, нужно создать экземпляр главного представления – при этом мы сразу подпишемся на все события и загрузим данные в коллекцию (при этом автоматически произойдет отрисовка всех моделей). Затем мы создадим экземпляр роутера, но чтобы он заработал, нужно будет вызвать метод Backbone.history.start(); В общем, вот весь код:
1: // Предсталение нашего приложения - представление высокого уровня
2: // Инициализируем его первым, так как тут подкачиваются данные из
3: // локального хранилица
4: var app = new AppView();
5:
6: // Роутер
7: var router = new WorkspaceRouter();
8:
9: // Запускаем историю.
10: Backbone.history.start();
Вот, собственно, и всё. Можно запускать и лицезреть результаты При перезагрузке страницы, все данные должны подкачиваться, роутеры отрабатываться, представления отображаться.
На этот раз весь исходный код я разместил на github, демо можно поглядеть тут. По сути, интерес представляют только 2 файла: разметка и код приложения.
Отдельная благодарность организаторам, докладчикам и просто участникам dotnetconf, было здорово и познавательно.
Дополнительные материалы и использованная литература
- Наследование для классов в JavaScript
- Библиотека backbone (анг / рус)
- Очень помогают исходники с аннотациями (анг)
- Доклад Константина Зотова ( слайды, демо )
- Доклад Евгения Абросимова. Жаль, пока нет слайдов.
- Мега книжка “Веб приложения на javascript” (о которой я уже писал)
- Отличный пример Todos, написанный неким Jérôme Gravel-Niquet
Спасибо,очень помогло разобраться.
ОтветитьУдалитьСпасибо, жалко, что картинки потерялись.
ОтветитьУдалитьХм, странно. У меня всё показывается.
Удалить