5 May 2011

Написание сложных интерфейсов с Backbone.js

JavaScript
image

Backbone.js это каркас для создания RIA JavaScript приложений, его автором является Jeremy Ashkenas, создатель CoffeeScript, Backbone является частью компании Document Cloud ей же «принадлежит» Underscrore.js. Backbone — очень легкая библиотека, помогающая вам создавать интерфейсы. Она может работать с любыми библиотеками, к которым вы привыкли.
Backbone это набор классов, размером менее 4Кб, которые формируют структуру вашего кода и помогают создавать качественные MVC веб-приложения.
Backbone формирует структуру тяжелых JavaScript приложений, внесением моделей с key-value подобным хранилищем и своими событиями, коллекций с богатыми API, видов (ориг. views) с декларативной обработкой событий и соединяет все это в в одно приложение, поддерживающее RESTful JSON интерфейс.

Backbone не может работать без Underscore.js. Для поддержки REST API и работы с DOM элементами в Backbone.View настоятельно рекомендуется подключить json2.js и jQuery-подобную библиотеку: jQuery или Zepto

В статье будет рассмотрена структура Backbone.js, будет поэтапно создано простое Todo приложение.

Вот те рычаги, которые нам дает Backbone

Хранилище типа ключ-значение и пользовательские события


Когда содержание модели изменяется все объекты, которые были подписаны на изменения модели получают уведомления и могут предпринять дальнейшие действия. Например, виды (ориг. views) слушают изменения в модели и обновляют свое состояние вместо того, чтобы модель меняла состояние видов. Применяется паттерн loose coupling «слабая связь».

Богатый API перечисляемых функций


Backbone поставляется с набором очень полезных функций для обработки ваших данных. В отличии от других языков, в JavaScript массивы «недоделанные», что доставляет много хлопот если вы работаете с большим объемом данных.

Виды с декларативной обработкой событий


Те дни, когда вы писали спагетти-подобный код подходят к концу. Вы можете программно определить какой callback ассоциирован с каким объектом.

RESTful JSON интерфейс


Если вам необходимо пообщаться с сервером, вам приходится выполнить AJAX запрос в коде «вида» и получать то, что вам необходимо. Все это частично упрощается с использованием различных xhr-адаптеров, WebSockets и localStorage, но можно сделать проще. Такую сущность следует поделить на несколько мелких, Backbone нам в этом поможет:
Backbone дает нам возможность отделить данные от представления. Модель, которая работает с данными и синхронизируется с сервером, тогда когда вид слушает изменения модели и изменяет свое состояние (отрисовывает данные в HTML)
Сразу перечислю ответы на вопросы, которые могут сейчас возникнуть:

Заменяет ли оно jQuery?

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

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

Почему я должен использовать её?

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

Где я должен использовать её?

Backbone идеально подходит для создания тяжелых интерфейсов и приложений, управляемых данными. Например, интерфейс GMail, новый Twitter или другие связанные приложения. Backbone позволяет создавать сложные приложения проще.

С её помощью вы можете создавать и простые html страницы, насыщенные JavaScript, но по большему счету Backbone предназначен для создания веб-приложений.

Похож ли он на Cappuccino или Sproutcore?

И да и нет. Да потому, что как и в вышеупомянутых фрэймворках, основная цель — создание сложных интерфейсов для веб-приложений. Отличаются они тем, что Backbone очень легкий, ни одна вышеупомянутая библиотека не может сравниться с ним.
Backbone предельно легкий, менее 4кб.
На самом деле про 4kb это не правда: Backbone 4кб + Underscore.js 3кб + jQuery 31Кб = 38кб

Cappuccino заставляет вас писать код на Objective-J, тогда как виды Sproutcore должны объявляться в JavaScript коде. Но я не могу назвать ни один из этих подходом не верным, но с Backbone вы пишете обычный JavaScript и используете обычный HTML и CSS вам практически ничего не нужно менять.

Могу ли я использовать другие библиотеки вместе с Backbone?

Безусловно. Не только обращение к DOM, обертки AJAX, но также шаблоны и загрузчики скриптов. Backbone имеет очень-очень слабую связь (very, very loosely coupled) — это значит, что вы можете использовать практически все ваши инструменты в сочетании с Backbone.

Строение Backbone


Вначале Backbone состоял только из моделей, видов и коллекций — контроллеры не входили. Со временем контроллеры были добавлены. Сейчас Backbone состоит из 4 основных классов:
  • Модель (Model)
  • Коллекция (Collection)
  • Вид (View)
  • Контроллер (Controller)
Кратко пробежимся по основным классам, затем на основе этих знаний создадим простое приложение.

Модель

Моделями называются разные вещи в различных MVC фрэймворках. В Backbone моделью называется отдельная сущность, ближайший аналог — запись в базе. Но здесь нет жестких правил. С сайта фрэймворка:
Модели это сердце всех JavaScript приложений, содержащие интерактивные данные также как и большую часть логики, обрамляющую их: преобразования, валидация, вычисляемые свойства, разграничение прав доступа.
Модель — это способ чтения и записи свойств или атрибутов в наборе данных. Вот пример модели:
var Game = Backbone.Model.extend({});

Давайте немного усложним:
var Game = Backbone.Model.extend({
       initialize: function(){
           alert("Oh hey! ");
       },
       defaults: {
           name: 'Default title',
           releaseDate: 2011,
       }
   });

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

Давайте посмотрим как читать и записывать атрибуты. Но сперва, создадим объект:
// Создаем новую игру
var portal = new Game({ name: "Portal 2", releaseDate: 2011});

// дата релиза находится в releaseDate
var release = portal.get('releaseDate'); // 2011

// Меняем имя игры
portal.set({ name: "Portal 2 by Valve"});

Вы не можете работать с атрибутами напрямую (object.attribute) вы должны вызвать метод для изменения или получения данных (думаю с появлением Proxy ситуация изменится).
Сейчас все данные находятся в памяти приложения. Давайте сохраним их на сервер:
portal.save();

Вы ожидали что-то большее? AJAX? Одной строчкой мы отправляем запрос на сервер. Помните, что тип запроса меняется: если вы создаете новый объект, то будет отправлен POST запрос, иначе будет PUT.
Я кратко затронул модели. Backbone дает значительно больше возможностей по работе с моделями. Подробности можно найти в документации.

Коллекции

Коллекции в Backbone это просто набор моделей. По аналогии с базой данных коллекции — это результаты запросов к БД, содержащие строки (Модели).
Создадим коллекцию игр:
var GamesCollection = Backbone.Collection.extend({
    model : Game
});

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

Теперь вы можете работать с данными. Для примера, давайте расширим коллекцию, добавив метод, определяющий старые игры.
var GamesCollection = Backbone.Collection.extend({
  model : Game,
  old : function() {
    return this.filter(function(game) {
      return game.get('releaseDate') < 2009;
    });
  }
});

Просто, не правда ли? Мы просто проверяем те игры, которые были созданы до 2009 года и возвращаем их. Также вы можете управлять коллекцией напрямую:
var games = new GamesCollection
games.get(0);

Пример выше создает новую коллекцию и получает модель с ID 0. Вы можете найти элемент в определенной позиции через ссылку на позицию объекта: games.at(0);

И, наконец, вы можете динамически пополнять вашу коллекцию вот так:
var GamesCollection = Backbone.Collection.extend({
  model : Game,
  url: '/games'
});

var games = new GamesCollection
games.fetch();


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

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

Вид

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

В обязанности видов входит:
  • Слушание событий DOM, моделей и коллекций.
  • Представление состояния приложения (отрисовка вида).
Давайте создадим простой вид:
var GameView = Backbone.View.extend({
  tagName: "div",
  className: "game",
  render: function() {
    // код отрисовывающий вид
  }
});

Все довольно просто. Я просто определяю, какой HTML элемент должен использоваться для обертки вида (tagName и className).

Следующий код отрисовывает вид:
  render : function() {
    this.el.innerHTML = this.model.get('name');

    // Или используя jQuery:
    $(this.el).html(this.model.get('name'));
  }

el ссылается на DOM элемент, который представляет этот вид. Мы просто кладем имя игры в HTML элемент. Очевидно, что используя jQuery все немного проще.

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

Backbone имеет собственный шаблонизатор (часть Underscore.JS) но вы свободны применять любой вид шаблона.

Наконец, давайте посмотрим, как виды слушают события. Сначала DOM события.
events: {
    'click .name': 'handleClick'
},

handleClick: function(){
    alert('In the name of science... you monster');
    // Other actions as necessary
}

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

Теперь свяжем наш вид с моделью:
var GameView = Backbone.View.extend({
    initialize: function (args) {
        _.bindAll(this, 'changeName');
        this.model.bind('change:name', this.changeName);
    }
});

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

bindAll это метод Underscore, который связывает контекст this с функцией. Это особенно полезно в событиях.

Теперь как только имя модели будет изменено, вызовется функция changeName. Вы можете использовать другие префиксы вместо change: для опроса состояния модели.

Контроллер

Контроллеры позволяют создавать приложение, запоминающие свое состояние в url hash (hashbangs).
var Hashbangs = Backbone.Controller.extend({
    routes: {
        "!/":      "root",
        "!/games": "games",
    },
    root: function() {
        // Подготавливаем начальную страницу и другое
    },

    games: function() {
        // Перерисовка видов в коллекции игр
    }
});

Это очень похоже на роуты в традиционных серверных MVC фрэймворках. Например, !/games будет связан с функцией в то время как URL в браузере будет domain/#!/games.

Используя hashbangs, вы можете создавать веб-приложения, которые запоминают свое состояние и могут индексироваться Google.

Если вы боитесь, что это сломает кнопку Назад, то Backbone может позаботиться об этом.
// Создание контроллера
var ApplicationController = new Controller; 

Backbone.history.start();

Backbone следит за изменением #hash и оповещает контроллеры.

Теперь вы знаете основы Backbone, попробуем создать тестовое приложение.

Пример: Список Todo


Это приложение было создано Jérôme Gravel-Niquet. Оно использует простой localStorage адаптер для сохранения ваших данных в браузере (не останавливаюсь на адаптере, посмотрите его код — там все предельно просто). Посмотрите, что получится в конце, чтобы лучше понять код: Список Todo

Модель Туду

Наша базовая модель описывает элемент туду списка, который имеет атрибуты content, order и done.
  window.Todo = Backbone.Model.extend({

    // Если вы не написали текст, это будет заглушкой
    // Это немного другой подход, в начале этой статьи мы использовали default
    EMPTY: "empty todo...",

    // Если модель не имеет `content`, подсовываем по умолчанию
    initialize: function() {
      if (!this.get("content")) {
        this.set({"content": this.EMPTY});
      }
    },

    // Переключаем статус `done`
    toggle: function() {
      this.save({done: !this.get("done")});
    },

    // Удаляем из localStorage и удаляем вид
    clear: function() {
      this.destroy();
      this.view.remove();
    }

  });


Коллекция Туду

Коллекция туду хранится в localStorage
  window.TodoList = Backbone.Collection.extend({

    // Эта коллекция будет состоять из моделей Todo
    model: Todo,

    // Сохраняем все туду под неймспейсом "todos" в localStorage
    localStorage: new Store("todos"),

    // Фильтр для получения списка тудушек, которые завершены
    done: function() {
      return this.filter(function(todo){ return todo.get('done'); });
    },

    // Фильтр для получения списка тудушек, которые не завершены
    remaining: function() {
      return this.without.apply(this, this.done());
    },

    // Мы сохраняем наши туду последовательно, в то время когда в базе они могут храниться хаотично.
    // В нашем случае мы используем GUID в качестве ключа. Этот метод получает следующий ид объекта.
    nextOrder: function() {
      if (!this.length) return 1;
      return this.last().get('order') + 1;
    },

    // Туду отсортированы по порядку добавления в список
    comparator: function(todo) {
      return todo.get('order');
    }

  });

  // Создадим глобальную коллекцию тудушек
  window.Todos = new TodoList;


Вид — Элемент туду

  // DOM Элемент для туду
  window.TodoView = Backbone.View.extend({

    // это элемент списка
    tagName:  "li",

    // Кэшируем шаблон
    // Код шаблона ниже в статье
    template: _.template($('#item-template').html()),

    // События DOM, которые связаны с туду
    events: {
      "click .check"              : "toggleDone",
      "dblclick div.todo-content" : "edit",
      "click span.todo-destroy"   : "clear",
      "keypress .todo-input"      : "updateOnEnter"
    },

    // TodoView слушает изменения модели и перерисовывает себя.
    // так как эта связь один-вид-одна-модель, то мы просто устанавливаем 
    // связь с моделью напрямую.
    initialize: function() {
      _.bindAll(this, 'render', 'close');
      this.model.bind('change', this.render);
      this.model.view = this;
    },

    // Перерисовываем содержимое
    render: function() {
      $(this.el).html(this.template(this.model.toJSON()));
      this.setContent();
      return this;
    },

    // Для избежания XSS мы используем `jQuery.text` для изменения контента туду
    setContent: function() {
      var content = this.model.get('content');
      this.$('.todo-content').text(content);
      this.input = this.$('.todo-input');
      this.input.bind('blur', this.close);
      this.input.val(content);
    },

    // Переключаем состояние "done" у модели
    toggleDone: function() {
      this.model.toggle();
    },

    // Переключаем вид в режим редактирования
    edit: function() {
      $(this.el).addClass("editing");
      this.input.focus();
    },

    // Закрываем режим редактирования, сохраняем изменения
    close: function() {
      this.model.save({content: this.input.val()});
      $(this.el).removeClass("editing");
    },

    // Если нажать `enter`, то туду сохранится
    updateOnEnter: function(e) {
      if (e.keyCode == 13) this.close();
    },

    // Удаление DOM элемента
    remove: function() {
      $(this.el).remove();
    },

    // Удаление элемента и модели
    clear: function() {
      this.model.clear();
    }

  });


Вид — Приложение

Это базовый вид нашего приложения
  window.AppView = Backbone.View.extend({

    // Вместо того, чтобы создавать новый элемент привяжемся к существующему HTML скелету
    el: $("#todoapp"),

    // Шаблон для статистики
    // Код шаблона ниже в статье
    statsTemplate: _.template($('#stats-template').html()),

    // Составляем список событий для создания новых туду, очистки завершенных
    events: {
      "keypress #new-todo":  "createOnEnter",
      "keyup #new-todo":     "showTooltip",
      "click .todo-clear a": "clearCompleted"
    },

    // При инциализации мы начинаем слушать определенные события коллекции:
    // элементы изменены, добавлены, загружены. Тут же мы загружаем туду, которые были
    // сохранены в localStorage
    initialize: function() {
      _.bindAll(this, 'addOne', 'addAll', 'render');

      this.input    = this.$("#new-todo");

      Todos.bind('add',     this.addOne);
      Todos.bind('refresh', this.addAll);
      Todos.bind('all',     this.render);

      Todos.fetch();
    },

    // Перерисовка приложение - обновление статистики. Остальное не меняется.
    render: function() {
      var done = Todos.done().length;
      this.$('#todo-stats').html(this.statsTemplate({
        total:      Todos.length,
        done:       Todos.done().length,
        remaining:  Todos.remaining().length
      }));
    },

    // Создание элемента туду. Создаем вид и засовываем в `<ul>`
    addOne: function(todo) {
      var view = new TodoView({model: todo});
      this.$("#todo-list").append(view.render().el);
    },

    // Отрисовываем все элементы
    addAll: function() {
      Todos.each(this.addOne);
    },

    // Создаем атрибуты для новых туду
    newAttributes: function() {
      return {
        content: this.input.val(),
        order:   Todos.nextOrder(),
        done:    false
      };
    },

    // Если нажать return в поле ввода имени туду - создастся новая модель.
    // Создание модели вызовет определенные события которые по цепочке отрисуют новый элемент
    createOnEnter: function(e) {
      if (e.keyCode != 13) return;
      Todos.create(this.newAttributes());
      this.input.val('');
    },

    // Удаляем все завершенные туду, удаляем их модели.
    clearCompleted: function() {
      _.each(Todos.done(), function(todo){ todo.clear(); });
      return false;
    },

    // Показываем тултип после одной секунды ожидания.
    showTooltip: function(e) {
      var tooltip = this.$(".ui-tooltip-top");
      var val = this.input.val();
      tooltip.fadeOut();
      if (this.tooltipTimeout) clearTimeout(this.tooltipTimeout);
      if (val == '' || val == this.input.attr('placeholder')) return;
      var show = function(){ tooltip.show().fadeIn(); };
      this.tooltipTimeout = _.delay(show, 1000);
    }

  });

  // Наконец - создаем наше приложение
  window.App = new AppView;

Оригинал с аннотациями можно посмотреть тут

HTML шаблоны и CSS

    <!--Код скелета приложения-->
    <div id="todoapp">

      <div class="title">
        <h1>Todos</h1>
      </div>

      <div class="content">

        <div id="create-todo">
          <input id="new-todo" placeholder="What needs to be done?" type="text" />
          <span class="ui-tooltip-top" style="display:none;">Press Enter to save this task</span>
        </div>

        <div id="todos">
          <ul id="todo-list"></ul>
        </div>

        <div id="todo-stats"></div>

      </div>

    </div>
    
    <!--Шаблон элемента туду-->
    <script type="text/template" id="item-template">
      <div class="todo <%= done ? 'done' : '' %>">
        <div class="display">
          <input class="check" type="checkbox" <%= done ? 'checked="checked"' : '' %> />
          <div class="todo-content"></div>
          <span class="todo-destroy"></span>
        </div>
        <div class="edit">
          <input class="todo-input" type="text" value="" />
        </div>
      </div>
    </script>

    <!--Шаблон статистики приложения-->
    <script type="text/template" id="stats-template">
      <% if (total) { %>
        <span class="todo-count">
          <span class="number"><%= remaining %></span>
          <span class="word"><%= remaining == 1 ? 'item' : 'items' %></span> left.
        </span>
      <% } %>
      <% if (done) { %>
        <span class="todo-clear">
          <a href="#">
            Clear <span class="number-done"><%= done %></span>
            completed <span class="word-done"><%= done == 1 ? 'item' : 'items' %></span>
          </a>
        </span>
      <% } %>
    </script>

CSS не прикладываю, он очень длинный и совсем не по теме.

Все, наше тестовое приложение готово, результат можно посмотреть тут.

Чего полезного можно узнать из Backbone


Вот несколько уроков, которые вы можете выучить из архитектуры Backbone приложений:
  • Нам очень нужен MVC для интерфейса. Традиционные методы создают код, который очень сильно завязан на себя, он грязный и его сложно поддерживать.
  • Хранить данные и состояние в DOM это плохая идея. Это особенно остро ощущается, если вы создаете приложение, которому необходимо, чтобы различные части приложение обновлялись, используя одни данные.
  • Толстые модели и худые контроллеры — это хорошо. Процесс разработки значительно упрощается, когда бизнес-логика управляется моделями.
  • Шаблоны очень важны. Добавляя HTML внутрь вашего JavaScript вы получаете плохую карму.
Достаточно сказать, что Backbone меняет представление о создании интерфейсов, по крайней мере, для меня. Если у вас есть вопросы — задавайте.

Источники, которые были использованы при написании


Create JavaScript Apps with Backbone.js (Jim Hoskins)
Getting Started with Backbone.js (Siddharth)
Адаптер backbone-locaLstorage
Пример Todos (Jérôme Gravel Niquet)
Исходник Todos.js с аннотациями

Что ещё почитать


Документация по Backbone на GitHub
Документация по Underscore на GitHub
Node Tutorial Part 19: Backbone.js (Alex Young)
Создание большого приложения на Javascript (оригинал Addy Osmani, перевод trurl123)
Tags:javascriptMVCbackboneunderscoreframeworktutorialjqueryzeptorestful json
Hubs: JavaScript
+98
97.3k 680
Comments 47
Top of the last 24 hours