Одностраничное приложение для управления задачами с использованием Backbone.js

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

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

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

Настройка

Вот структура файлов, которую мы будем использовать:

Настройка

Некоторые элементы этой иерархии очевидны: /css/styles.css и /index.html. Они содержат стили CSS и HTML-разметку. В контексте Backbone.js, модель - это место, где мы храним наши данные. Таким образом, наши задачи будут представлять собой просто модели. И так как у нас будет более одной задачи, мы организуем их в коллекцию.

Бизнес-логика распределяется между представлениями и главным файлом приложения, App.js. Backbone.js имеет только одну жесткую зависимость - Underscore.js. Эта система также очень хорошо работает с jQuery, поэтому обе они поставляются в папке vendor.

Все что нам сейчас нужно, это просто добавить некоторую HTML-разметку, и мы готовы начать:

<!doctype html>
<html>
  <head>
        <title>My TODOs</title>
        <link rel="stylesheet" type="text/css" href="css/styles.css" />
    </head>
    <body>
        <div class="container">
            <div id="menu" class="menu cf"></div>
            <h1></h1>
            <div id="content"></div>
        </div>
        <script src="js/vendor/jquery-1.10.2.min.js"></script>
        <script src="js/vendor/underscore.js"></script>
        <script src="js/vendor/backbone.js"></script>
        <script src="js/App.js"></script>
        <script src="js/models/ToDo.js"></script>
        <script src="js/collections/ToDos.js"></script>
        <script>
            window.onload = function() {
                // bootstrap
            }
        </script>
    </body>
</html>

Как видите, мы включили все внешние файлы JavaScript в нижней части, так как рекомендуется делать это как можно ближе к концу тега body. Мы также подготовили начальную загрузку приложения. У нас есть контейнер для контента, меню и название. Навигация представляет собой статический элемент, и мы не собираемся это менять. Мы заменим содержание заголовка и блока <div> под ним.

Планирование приложения

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

Области имен

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

App.js будет содержать класс, который отвечает за все:

// App.js
var app = (function() {
 
    var api = {
        views: {},
        models: {},
        collections: {},
        content: null,
        router: null,
        todos: null,
        init: function() {
            this.content = $("#content");
        },
        changeContent: function(el) {
            this.content.empty().append(el);
            return this;
        },
        title: function(str) {
            $("h1").text(str);
            return this;
        }
    };
    var ViewsFactory = {};
    var Router = Backbone.Router.extend({});
    api.router = new Router();
 
    return api;
 
})();

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

Переменная api является объектом, который возвращается и представляет открытые методы класса. views, models и collections будут выступать в качестве элементов, содержащих классы, возвращаемые Backbone.js. content является элементом JQuery, предназначенным для контейнера главного интерфейса пользователя.

Также здесь есть два вспомогательных метода. Первый обновляет этот контейнер. Второй устанавливает заголовок страницы. Затем определяется модуль под названием ViewsFactory. Он будет поставлять наши представления. И в самом конце мы создали маршрутизатор.

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

var ViewClass = Backbone.View.extend({ /* logic here */ });
var view = new ViewClass();

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

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

Определение компонентов

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

// views/menu.js
app.views.menu = Backbone.View.extend({
    initialize: function() {},
    render: function() {}
});

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

var ViewsFactory = {
    menu: function() {
        if(!this.menuView) {
            this.menuView = new api.views.menu({ 
                el: $("#menu")
            });
        }
        return this.menuView;
    }
};

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

Процесс

Точкой входа приложения является файл App.js и его метод init. Их мы будем вызывать в обработчике onload объекта window:

window.onload = function() {
    app.init();
}

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

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

Управление данными

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

// models/ToDo.js
app.models.ToDo = Backbone.Model.extend({
    defaults: {
        title: "ToDo",
        archived: false,
        done: false
    }
});

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

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

Как я уже сказал в начале, у нас будет много записей, и мы организуем их в коллекцию под названием ToDos:

// collections/ToDos.js
app.collections.ToDos = Backbone.Collection.extend({
    initialize: function(){
        this.add({ title: "Learn JavaScript basics" });
        this.add({ title: "Go to backbonejs.org" });
        this.add({ title: "Develop a Backbone application" });
    },
    model: app.models.ToDo
    up: function(index) {
        if(index > 0) {
            var tmp = this.models[index-1];
            this.models[index-1] = this.models[index];
            this.models[index] = tmp;
            this.trigger("change");
        }
    },
    down: function(index) {
        if(index < this.models.length-1) {
            var tmp = this.models[index+1];
            this.models[index+1] = this.models[index];
            this.models[index] = tmp;
            this.trigger("change");
        }
    },
    archive: function(archived, index) {
        this.models[index].set("archived", archived);
    },
    changeStatus: function(done, index) {
        this.models[index].set("done", done);
    }
});

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

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

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

Как вы можете догадаться из приведенного выше кода, this.models - это и есть тот массив, о котором мы говорили. archive и changeStatus устанавливают свойства предоставленного элемента. Мы вставляем здесь эти методы, потому что представления будут иметь доступ к коллекции ToDos, а не напрямую к задачам.

Кроме того, нам не нужно создавать модели из класса app.models.ToDo, но мы должны создать экземпляр объекта из коллекции app.collections.ToDos:

// App.js
init: function() {
    this.content = $("#content");
    this.todos = new api.collections.ToDos();
    return this;
}

Вывод нашего первого представления (Главное меню)

Первое, что мы должны вывести - это главное меню приложения:

// views/menu.js
app.views.menu = Backbone.View.extend({
    template: _.template($("#tpl-menu").html()),
    initialize: function() {
        this.render();
    },
    render: function(){
        this.$el.html(this.template({}));
    }
});

Это всего лишь девять строк кода, но в них происходит много интересных вещей. Первая строка устанавливает шаблон. Если вы помните, мы добавили в наше приложение Underscore.js.

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

_.template(templateString, [data], [settings])

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

Итак, этот код принимает HTML-строку, но что здесь делает $("#tpl-menu").html()? Когда мы разрабатываем небольшое одностраничное приложение, мы обычно вставляем шаблоны непосредственно в страницу, как показано ниже:

// index.html
<script type="text/template" id="tpl-menu">
    <ul>
        <li><a href="#">List</a></li>
        <li><a href="#archive">Archive</a></li>
        <li class="right"><a href="#new">+</a></li>
    </ul>
</script>

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

Метод render действительно важен для Backbone.js. Это функция, которая отображает данные. Как правило, вы привязываете события, исходящие от моделей, непосредственно к этому методу. Тем не менее, для главного меню такое поведение нам не требуется:

this.$el.html(this.template({}));

this.$el - это объект, создаваемый фреймворком, и каждое представление содержит его по умолчанию (перед el добавляется $, потому что у нас подключен JQuery). И по умолчанию, это пустой блок <div></div>. Конечно, вы можете это изменить с помощью свойства tagName.

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

this.$el = $(this.template({}));

Суть в том, что если вы хотите увидеть изменения в браузере, вы должны вызвать метод визуализации раньше, чтобы добавить представление в DOM. В противном случае будет прикреплен только пустой блок <div>. Существует также еще один сценарий, при котором у вас будут вложенные представления.

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

Представление готово, и теперь мы должны его инициализировать. Давайте добавим его в наш модуль фабрики:

// App.js
var ViewsFactory = {
    menu: function() {
        if(!this.menuView) {
            this.menuView = new api.views.menu({ 
                el: $("#menu")
            });
        }
        return this.menuView;
    }
};

В конце просто вызовите метод menu в области начальной загрузки:

// App.js
init: function() {
    this.content = $("#content");
    this.todos = new api.collections.ToDos();
    ViewsFactory.menu();
    return this;
}

Обратите внимание, что в то время как мы создаем новый экземпляр объекта из класса меню навигации, мы передаем уже существующий DOM-элемент $("#menu"). Таким образом, свойство this.$el внутри представления является собственно указанием на $("#menu").

Добавление маршрутов

Backbone.js поддерживает операции push state. Другими словами, вы можете манипулировать текущими URL-адресами браузера и переходить на разные страницы. Тем не менее, мы будем придерживаться старых добрых URL-адресов типа хэш, например /#edit/3:

// App.js
var Router = Backbone.Router.extend({
    routes: {
        "archive": "archive",
        "new": "newToDo",
        "edit/:index": "editToDo",
        "delete/:index": "delteToDo",
        "": "list"
    },
    list: function(archive) {},
    archive: function() {},
    newToDo: function() {},
    editToDo: function(index) {},
    delteToDo: function(index) {}
});

Выше приведен наш маршрутизатор. В хэш-объекте определено пять маршрутов. Идея заключается в том, что вы набираете адрес в строке браузера, и это значение будет функцией, которая должна вызываться. Обратите внимание, что в двух маршрутах присутствует :index.

Вот синтаксис, который необходимо использовать, если вы хотите поддерживать динамические URL-адреса: нашем случае, если вы вводите #edit/3, editToDo будет выполняться с параметром index=3. Последняя строка содержит просто пустое пространство, что означает, что она обрабатывает главную страницу нашего приложения.

Вывод списка всех задач

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

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

// в App.js фабрика представлений
list: function() {
    if(!this.listView) {
        this.listView = new api.views.list({
            model: api.todos
        });
    }   
    return this.listView;
}

Обратите внимание, что мы передаем в коллекцию. Это важно, потому что позже мы будем использовать this.model, чтобы получить доступ к хранилищу данных. Фабрика возвращает наше представление списка, но именно маршрутизатор должен добавить его на страницу:

// в маршрутизаторе App.js'
list: function(archive) {
    var view = ViewsFactory.list();
    api
    .title(archive ? "Archive:" : "Your ToDos:")
    .changeContent(view.$el);
    view.setMode(archive ? "archive" : null).render();
}

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

// views/list.js
app.views.list = Backbone.View.extend({
    mode: null,
    events: {},
    initialize: function() {
        var handler = _.bind(this.render, this);
        this.model.bind('change', handler);
        this.model.bind('add', handler);
        this.model.bind('remove', handler);
    },
    render: function() {},
    priorityUp: function(e) {},
    priorityDown: function(e) {},
    archive: function(e) {},
    changeStatus: function(e) {},
    setMode: function(mode) {
        this.mode = mode;
        return this;
    }
});

Свойство mode будет использоваться во время визуализации. Если его значение mode="archive", то будут выводиться только архивные задачи. events - это объект, который мы будем заполнять сразу.

Это место, где мы размещаем отображение событий DOM. Остальные методы являются ответами на взаимодействия пользователя, они непосредственно связаны с соответствующими элементами.

Например, priorityUp и priorityDown изменяют порядок сортировки задач. archive перемещает элемент в область архива. changeStatus просто помечает задачу как выполненную. Интересно, что происходит внутри метода initialize.

Ранее мы говорили, что обычно вы связываете изменения в модели (в нашем случае коллекции) с методом представления render. Вы можете ввести this.model.bind('change', this.render). Но очень скоро вы поймете, что это ключевое слово this в методе render не будет указывать на само представление.

Это происходит потому, что диапазон изменяется. Для решения этой проблемы мы создаем обработчик с уже определенным диапазоном. Именно для этого используется функция Underscore bind.

Ниже приводится реализация метода render:

// views/list.js
render: function() {)
    var html = '<ul class="list">', 
        self = this;
    this.model.each(function(todo, index) {
        if(self.mode === "archive" ? todo.get("archived") === true : todo.get("archived") === false) {
            var template = _.template($("#tpl-list-item").html());
            html += template({ 
                title: todo.get("title"),
                index: index,
                archiveLink: self.mode === "archive" ? "unarchive" : "archive",
                done: todo.get("done") ? "yes" : "no",
                doneChecked: todo.get("done")  ? 'checked=="checked"' : ""
            });
        }
    });
    html += '</ul>';
    this.$el.html(html);
    this.delegateEvents();
    return this;
}

Мы пропускаем через цикл все модели в коллекции и генерируем HTML-строку, которая позже вставляется в DOM-элемент представления. Здесь проводится несколько проверок, которые определяют архивные и активные задачи. С помощью специального маркера архивные задачи помечаются, как выполненные.

Для этого нам нужно передать этому элементу атрибут checked=="checked". Вы можете заметить, что мы используем this.delegateEvents(). В нашем случае это необходимо, потому что мы прикрепляем и убираем представления из DOM. Да, мы не заменяем главный элемент, а удаляем обработчики событий.

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

// index.html
<script type="text/template" id="tpl-list-item">
    <li class="cf done-<%= done %>" data-index="<%= index %>">
        <h2>
            <input type="checkbox" data-status <%= doneChecked %> />
            <a href="javascript:void(0);" data-up>↑</a>
            <a href="javascript:void(0);" data-down>↓</a>
            <%= title %>
        </h2>
        <div class="options">
            <a href="#edit/<%= index %>">edit</a>
            <a href="javascript:void(0);" data-archive><%= archiveLink %></a>
            <a href="#delete/<%= index %>">delete</a>
        </div>
    </li>
</script>

Обратите внимание, что здесь присутствует класс CSS, определяемый вызовом done-yes, который помечает задачу зеленым фоном.

Кроме того, есть куча ссылок, которые мы будем использовать, чтобы реализовать нужный функционал. Все они имеют атрибуты данных. Основной узел элемента, li, имеет атрибут data-index. Значение этого атрибута показывает индекс задачи в коллекции.

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

Пора добавить в представление некоторые события:

// views/list.js
events: {
    'click a[data-up]': 'priorityUp',
    'click a[data-down]': 'priorityDown',
    'click a[data-archive]': 'archive',
    'click input[data-status]': 'changeStatus'
}

В Backbone.js определение мероприятия представляет собой просто хэш. Вы сначала вводите название мероприятия, а затем селектор. Значения свойств на самом деле являются методами представления:

// views/list.js
priorityUp: function(e) {
    var index = parseInt(e.target.parentNode.parentNode.getAttribute("data-index"));
    this.model.up(index);
},
priorityDown: function(e) {
    var index = parseInt(e.target.parentNode.parentNode.getAttribute("data-index"));
    this.model.down(index);
},
archive: function(e) {
    var index = parseInt(e.target.parentNode.parentNode.getAttribute("data-index"));
    this.model.archive(this.mode !== "archive", index); 
},
changeStatus: function(e) {
    var index = parseInt(e.target.parentNode.parentNode.getAttribute("data-index"));
    this.model.changeStatus(e.target.checked, index);       
}

Здесь мы используем вхождение в обработчик e.target. Он указывает на DOM-элемент, который вызвал событие. Мы получаем индекс выбранной задачи и обновляем модель в коллекции. С помощью этих четырех функций мы закончили наш класс, и теперь данные выводятся на странице.

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

list: function(archive) {
    var view = ViewsFactory.list();
    api
    .title(archive ? "Archive:" : "Your ToDos:")
    .changeContent(view.$el);
    view.setMode(archive ? "archive" : null).render();
},
archive: function() {
    this.list(true);
}

Выше приведен тот же обработчик маршрутов, что и до этого, однако с true в качестве параметра.

Добавление и редактирование задач

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

// App.js / views factory
form: function() {
    if(!this.formView) {
        this.formView = new api.views.form({
            model: api.todos
        }).on("saved", function() {
            api.router.navigate("", {trigger: true});
        })
    }
    return this.formView;
}

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

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

Прежде чем мы продолжим работу с кодом представления, давайте рассмотрим HTML-шаблон:

<script type="text/template" id="tpl-form">
    <form>
        <textarea><%= title %></textarea>
        <button>save</button>
    </form>
</script>

У нас есть textarea и button. Шаблон ожидает параметр title, который, если мы добавляем новую задачу, должен представлять собой пустую строку:

// views/form.js
app.views.form = Backbone.View.extend({
    index: false,
    events: {
        'click button': 'save'
    },
    initialize: function() {
        this.render();
    },
    render: function(index) {
        var template, html = $("#tpl-form").html();
        if(typeof index == 'undefined') {
            this.index = false;
            template = _.template(html, { title: ""});
        } else {
            this.index = parseInt(index);
            this.todoForEditing = this.model.at(this.index);
            template = _.template($("#tpl-form").html(), {
                title: this.todoForEditing.get("title")
            });
        }
        this.$el.html(template);
        this.$el.find("textarea").focus();
        this.delegateEvents();
        return this;
    },
    save: function(e) {
        e.preventDefault();
        var title = this.$el.find("textarea").val();
        if(title == "") {
            alert("Empty textarea!"); return;
        }
        if(this.index !== false) {
            this.todoForEditing.set("title", title);
        } else {
            this.model.add({ title: title });
        }   
        this.trigger("saved");      
    }
});

Представление состоит всего из 40 строк кода, но оно делает свою работу хорошо. К нему прикреплено только одно событие - клик на кнопку Сохранить. Метод визуализации действует по-разному в зависимости от передаваемого параметра index. Например, если мы редактируем задачу, мы передаем индекс и выбираем конкретную модель.

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

Во-первых, в визуализации мы использовали метод .focus(), чтобы, когда представление визуализируется, вывести в центре форму. Здесь опять же должна вызываться функция delegateEvents, потому что форма может быть откреплена и присоединена снова. Метод save стартует с e.preventDefault().

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

Существует два метода для маршрутизатора, который мы должны заполнить:

// App.js
newToDo: function() {
    var view = ViewsFactory.form();
    api.title("Create new ToDo:").changeContent(view.$el);
    view.render()
},
editToDo: function(index) {
    var view = ViewsFactory.form();
    api.title("Edit:").changeContent(view.$el);
    view.render(index);
}

Разница между ними заключается в том, что мы передаем в индексе, если маршрут edit/:index совпадает. И, конечно, заголовок страницы изменяется соответствующим образом.

Удаление записи из коллекции

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

delteToDo: function(index) {
    api.todos.remove(api.todos.at(parseInt(index)));
    api.router.navigate("", {trigger: true});
}

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

Заключение

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

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

РедакцияПеревод статьи «Single Page ToDo Application With Backbone.js»