Пользователь
0,0
рейтинг
4 мая 2012 в 23:44

Разработка → Написание сложных интерфейсов с Spine.js из песочницы

Введение в Spine.js


Spine представляет собой небольшой фрэймворк, который позволяет работать по схеме MVC, создавая приложения непосредственно на языке JavaScript, что обеспечивает логическое разделение кода, наследование моделей через классы и расширения. Также во многом этот инструмент базируется на Backbone.js API, так что те разработчики, которые имели дело с данным фрэймворком, без труда разберутся и в Spine (однако существует целый ряд существенных различий). Spine.js может работать совместно с HTML5 и асинхронными запросами сервера.

С каждым днем JavaScript фрэймворков появляется все больше и больше. Так что же делает Spine.js таким особенным?
  • Простая реализация контроллера (на основе Backbone's API)
  • Поддержка Ajax и HTML5 Local Storage
  • Асинхронная связь с сервером
  • Работает во всех современных браузерах (Chrome, Safari, Firefox, IE> = 7)
  • Spine Mobile extension
  • Простой и легкий
  • Хорошая документация

Взгляните на исходный код примера приложения, и решите для себя.

Классы Spine, модели и представления


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



Классы


В самом сердце Spine, используется эмулированние Object.create для уверенности в том, что объекты создаются динамически и могут быть использованы во время работы скрипта. Использование подобных классов можно увидеть в следующем примере:

var twitterClient = Spine.Class.create();  
//or  
var twitterClientWithArgs = Spine.Class.create({  
testMessage: "If it weren't for WebMD I would have never known what symptoms to mimic so I could get all these prescriptions from my doctor."  
});

Для инициализации классов используется метод init(). Разработчики Spine приняли решение не эксплуатировать функцию конструктора, поскольку использование ключевого слова «new» может вызвать некоторые проблемы при создании экземпляров класса.

var twitterClient = Spine.Class.create({  
 testMessage: "Hello world"  
});  
var myclient = twitterClient.init();

Все параметры, которые вы хотите использовать при инициализации объекта, следует передать через метод init(). Пример:

var twitterClient = Spine.Class.create({  
 init:function(testMessage){  
 this.testMessage = testMessage;  
 }  
});

Модели


В Spine модели используются для хранения данных приложения, а также для любой другой логики, связанной с этими данными. Следует придерживаться этой идеи, т.к. она является одним из требований в приложении, которое строится на MVC. Данные, связанные с моделями, хранятся в записи Model.records, и могут быть созданы с помощью функции Spine setup().

В следующем примере задается название модели и набор атрибутов в метод setup():

var Tweets = Spine.Model.setup("Tweet", ["username","tweet_message"]);

Функциональность моделей можно расширять, используя свойства того или иного класса следующим образом:

Tweets.include({  
 toTweetString: function(){  
 return("@" + this.username + " " + this.tweet_message);  
 }  
});

Создание модели осуществляется всё тем же простым методом .init ():

var mytweets = Tweets.init({username: "addyosmani", tweet_message: "hai twitter"});

Spine и Backbone имеют различные возможности рендеринга шаблонов и встраивания их в DOM модель.

Контроллеры


Spine контроллеры расширяют Spine.Class, а также наследуют все его свойства. Пример создания контроллера в Spine:

var TweetController = Spine.Controller.create({  
 init:function(){  
 //initial logic on instantiation  
 }  
})

Инициализация контроллеров происходит следующим образом:

var myTweetController = TweetController.init();  

Каждому контроллеру соответствует специальный элемент — 'el', который может быть передан через свойство экземпляра. Пример:

var myTweetController = TweetController.init({el: $('#tweets')}); 

Документация


Более подробно о классах, моделях и контроллерах вы можете почитать в документации к Spine.

Основные различия между Spine и Backbone


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

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

2. Backbone использует функции конструктора и прототипы, в то время как Spine использует эмулированную версию Object.create и моделируемую систему классов – что позволяет добиться того же самого эффекта наследования и на самом деле является очень интересным приёмом. Это одно из принципиальных отличий от Backbone. Оба подхода имеют право на существование.

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

Spine.js на практике


Пример: Bit.ly клиент





Когда вы работаете над SPA, много времени уходит на работу и взаимодействие с внешними данными (это могут быть ваши собственные данные или данные, полученные от какого-то API). Вам также хотелось бы использовать маршрутизацию, чтобы можно было сохранить состояние приложения. Для этого возможно придётся использовать localStorage, а также обработать запросы ajax.

Учитывая всё вышеперечисленное, мы собираемся создать bit.ly клиент, который позволит вам:

  • создавать привлекательные в размере URL непосредственно из вашего браузера;
  • архивировать свои bit.ly URL так, чтобы вы могли легко получить доступ к ним в любой момент;
  • предоставление статистики кликов (это будет реализовано через дополнительное 'представление', для демонстрации роутинга).

Предпосылки


Создание bit.ly плагина

Прежде чем мы начнем, нам необходимо найти хороший способ получения доступа к службам bit.ly: 1. сокращённому URL и 2. статистике кликов. Вместо того, чтобы мучиться с обычным JavaScript, мы будем использовать jQuery для того, чтобы более удобным и быстрым способом работать с ajax запросами. Этот подход также позволит нам написать более читабельное и понятное приложение.

Дополнительная поддержка store.js

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

Однако, эту проблему можно решить применением store.js (и то, на чем он основывается: json2.js). Ниже представлено содержание файла spine.model.local.js, который вы можете обновить, чтобы использовать хранилище, комментируя строки, которые отмечены ниже и заменив их своими.

Spine.Model.Local = {  
  extended: function(){  
    this.sync(this.proxy(this.saveLocal));  
    this.fetch(this.proxy(this.loadLocal));  
  },  
  saveLocal: function(){  
    var result = JSON.stringify(this);  
    //localStorage[this.name] = result;  
    store.set(this.name, result);  
  },  
  loadLocal: function(){  
    //var result = localStorage[this.name];  
    var result = store.get(this.name);  
    if ( !result ) return;  
    var result = JSON.parse(result);  
    this.refresh(result);  
  }  
}; 

Обработка jQuery шаблона

Spine и Backbone фрэймворки могут взаимодействовать с несколькими подходами (микрошаблонная обработка, mustache.js и так далее). Какой использовать, выбирать вам. В примере использован плагин jQuery tmpl, чтобы представить наши сокращенные записи URL и статистику кликов, используя шаблоны.

Разработка


Список того, что необходимо реализовать:

  • Модель, чтобы представить данные, которые будут содержаться в каждом сокращённом URL (Модель Url);
  • Контроллер, чтобы представить отдельные записи и действия, которые могут быть выполнены приложением (exports.URL);
  • Контроллер для вывода представления, ответственного за ввод новой записи bit.ly (exports.UrlsList);
  • Контроллер, чтобы вывести представление, ответственное за статистику кликов по той или иной записи (exports.Stats);
  • Универсальный контроллер, который будет обрабатывать маршрутизацию приложения (exports. UrlApp).

В примере используется jQuery, поскольку он идеально подходит для работы с шаблоном и плагином, но Spine так же может работать с Zepto или другими JavaScript библиотеками. Теперь давайте рассмотрим код нашего приложения:

Начальное кэширование


var exports = this;


Простой jQuery плагин


$.fn.toggleDisplay = function(bool){  
    if ( typeof bool == "undefined" ) {  
      bool = !$(this).filter(":first:visible")[0];  
    }  
    return $(this)[bool ? "show" : "hide"]();  
};  

Url модели:


var Url = Spine.Model.setup("Url", ["short_url", "long_url", "stats"]);  
Url.extend(Spine.Model.Local);  
Url.include({  
  validate: function(){  
    if ( !this.long_url )  
      return "long_url required"  
    if ( !this.long_url.match(/:\/\//))  
      this.long_url = "http://" + this.long_url  
  },  
  fetchUrl: function(){  
    if ( !this.short_url )  
      $.bitly(this.long_url, this.proxy(function(result){  
        this.updateAttributes({short_url: result});  
      }));  
  },  
  fetchStats: function(){  
    if ( !this.short_url ) return;  
    $.bitly.stats(this.short_url, this.proxy(function(result){  
      this.updateAttributes({stats: result});  
    }));  
  }  
});  
Url.bind("create", function(rec){  
  rec.fetchUrl();  
});  

Контроллер exports.Urls:


exports.Urls = Spine.Controller.create({  
  events: {  
    "click .destroy": "destroy",  
    "click .toggleStats": "toggleStats"  
  },  
  proxied: ["render", "remove"],  
  template: function(items){  
    return $("#urlTemplate").tmpl(items);  
  },  
  init: function(){  
    this.item.bind("update",  this.render);  
    this.item.bind("destroy", this.remove);  
  },  
  render: function(){  
    this.el.html(this.template(this.item));  
    return this;  
  },  
  toggleStats: function(){  
    this.navigate("/stats", this.item.id, true);  
  },  
  remove: function(){  
    this.el.remove();  
  },  
  destroy: function(){  
    this.item.destroy();  
  }  
});  

Контроллер exports.UrlsList:


exports.UrlsList = Spine.Controller.create({  
    elements: {  
      ".items": "items",  
      "form":   "form",  
      "input":  "input"  
    },  
    events: {  
      "submit form": "create",  
    },  
    proxied: ["render", "addAll", "addOne"],  
    init: function(){  
      Url.bind("create",  this.addOne);  
      Url.bind("refresh", this.addAll);  
    },  
    addOne: function(url){  
      var view = Urls.init({item: url});  
      this.items.append(view.render().el);  
    },  
    addAll: function(){  
      Url.each(this.addOne);  
    },  
    create: function(e){  
      e.preventDefault();  
      var value = this.input.val();  
      if (value)  
        Url.create({long_url: value});  
      this.input.val("");  
      this.input.focus();  
    }  
  });  

Контроллер exports.Stats:


exports.Stats = Spine.Controller.create({  
  events: {  
    "click .back": "back"  
  },  
  proxied: ["change", "render"],  
  init: function(){  
    Url.bind("update", this.render);  
  },  
  template: function(items){  
    return $("#statsTemplate").tmpl(items);  
  },  
  render: function(){  
    if ( !this.item ) return;  
    this.el.html(this.template(this.item));  
  },  
  change: function(item){  
    this.item = item;  
    this.navigate("/stats", item.id);  
    this.item.fetchStats();  
    this.render();  
    this.active();  
  },  
  back: function(){  
    this.navigate("/list", true);  
  }  
});

Контроллер exports.UrlApp:


exports.UrlApp = Spine.Controller.create({  
  el: $("body"),  
  elements: {  
    "#urls": "urlsEl",  
    "#stats": "statsEl"  
  },  
  init: function(){  
    this.list = UrlsList.init({el: this.urlsEl});  
    this.stats = Stats.init({el: this.statsEl});  
    this.manager = Spine.Controller.Manager.init();  
    this.manager.addAll(this.list, this.stats);  
    this.routes({  
      "": function(){ this.list.active() },  
      "/list": function(){ this.list.active() },  
      "/stats/:id": function(id){ this.stats.change(Url.find(id)) }  
    });  
    Url.fetch();  
    Spine.Route.setup();  
  }  
});  

Наконец, для того чтобы завершить инициализацию нашего контроллера 'app':

exports.App = UrlApp.init();

Код для сокращения URL и статистики кликов для Bit.ly


(function($){  
  var defaults = {  
    version:    "3.0",  
    login:      "legacye",  
    apiKey:     "R_32f60d09cccde1f266bcba8c242bfb5a",  
    history:    "0",  
    format:     "json"  
  };  
  $.bitly = function( url, callback, params ) {  
    if ( !url || !callback ) throw("url and callback required");  
    var params = $.extend( defaults, params );  
    params.longUrl = url;  
    return $.getJSON("http://api.bit.ly/shorten?callback=?", params, function(data, status, xhr){  
      callback(data.results[params.longUrl].shortUrl, data.results[params.longUrl], data);  
    });  
  };  
  $.bitly.stats = function( url, callback, params ) {  
    if ( !url || !callback ) throw("url and callback required");  
    var params = $.extend( defaults, params );  
    params.shortUrl = url;  
    return $.getJSON("http://api.bitly.com/v3/clicks?callback=?", params, function(data, status, xhr){  
      callback(data.data.clicks[0], data);  
    });  
  };  
})(jQuery);  

Application Index/HTML:


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

<!DOCTYPE html>  
<html>  
<head>  
  <link rel="stylesheet" href="css/application.css" type="text/css" charset="utf-8">  
  <script src="lib/LAB.min.js" type="text/javascript" charset="utf-8"></script>  
  <script type="text/javascript">  
    $LAB  
    .script("lib/json.js")  
    .script("lib/jquery.js")  
    .script("lib/jquery.tmpl.js")  
    .script("lib/jquery.bitly.js")  
    .script("lib/store.min.js")  
    .script("lib/spine.js")  
    .script("lib/spine.model.local.js")  
    .script("lib/spine.controller.manager.js")  
    .script("lib/spine.route.js")  
    .script("app/models/url.js")  
    .script("app/application.js");  
  </script>  
  <script type="text/x-jquery-tmpl" id="urlTemplate">  
    <div class="item">  
      <div class="show">  
        <span class="short">  
          ${long_url}  
        </span>  
        <span class="long">  
          {{if short_url}}  
            <a href="${short_url}">${short_url}</a>  
          {{else}}  
            Generating...  
          {{/if}}  
        </span>  
        <a class="toggleStats"></a>  
        <a class="destroy"></a>  
      </div>  
    </div>  
  </script>  
  <script type="text/x-jquery-tmpl" id="statsTemplate">  
    <div class="stats">  
      <a class="back">Back</a>  
      <h1>Click Statistics</h1>  
      <h1 class="longUrl">${long_url}</h1>  
      <p>Short URL:  
        {{if short_url}}  
          <a href="${short_url}">${short_url}</a>  
        {{else}}  
          Generating...  
        {{/if}}  
      </p>  
      {{if stats}}  
        <p>Global clicks: ${stats.global_clicks}</p>  
        <p>User clicks: ${stats.user_clicks}</p>  
      {{else}}  
        Fetching...  
      {{/if}}  
    </div>  
  </script>  
</head>  
<body>  
  <div id="views">  
    <div id="urls">  
      <h1>Bit.ly Client</h1>  
      <form>  
        <input type="text" placeholder="Enter a URL">  
      </form>  
      <div class="items"></div>  
    </div>  
    <div id="stats">  
    </div>  
  </div>  
</body>  
</html>

Примечание:


  • Для кроссбраузерной совместимости данный пример должен быть запущен на живом или локальном веб сервере. Используйте MAMP/WAMP если необходимости;
  • Для проверки статистики кликов я рекомендую использовать URL сайтов, которые являются наиболее популярными. Например, информация о сайте www.google.com наверняка присутствует в базе данных Bit.ly;
  • Демо пример использует мои собственные ключи API Bit.ly, которые должны быть заменены.
  • Круговые диаграммы сгенерированы при помощи Google Chart API. Для того чтобы не усложнять и так новый для вас подход, я сам выбирал изменение изображения диаграммы, но вы в любой момент можете легко переключиться на Visualization API;
  • Структура каталогов приложения — это полностью ваше дело. Некоторые разработчики предпочитают общую структуру — model / view / controller, а другие предпочитают иметь универсальную папку приложения, где все содержется в единственном файле. В примере использована структура папок, к которой я привык.
  • Если вы хотите сохранить уникальные «представления» для контента (например, одно представление для #ui/dashboard, а другое для #ui/stats), то вам необходимо разобрать работу spine.controller.manager.js, т.к. в этом файле есть решение данной задачи.

Вот и всё!

Результат
Исходники

Заключение


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

Использованные материалы:
Максим @global_max
карма
12,2
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (17)

  • +12
    Как грибы
    • +2
      какой полезный и важный комментарий.
      • +2
        Ваш тоже, как и мой…
  • НЛО прилетело и опубликовало эту надпись здесь
  • +1
    Книжка у него тоже хорошая — он там и Spine подробно расписывает — что, откуда, и почему, и Backbone, и JavascriptMVC, и ряд других вопросов.
  • –5
    Взгляните на исходный код примера приложения, и решите для себя.

    Взглянул на исходный код примера, и решил для себя — я за исходный код примера на Backbone.

    (function() {
      var $, Task, TaskApp, Tasks;
      var __hasProp = Object.prototype.hasOwnProperty, __extends = function(child, parent) {
        for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; }
        function ctor() { this.constructor = child; }
        ctor.prototype = parent.prototype;
        child.prototype = new ctor;
        child.__super__ = parent.prototype;
        return child;
      }, __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
      $ = jQuery;
      Task = (function() {
        __extends(Task, Spine.Model);
        function Task() {
          Task.__super__.constructor.apply(this, arguments);
        }
        Task.configure("Task", "name", "done");
        Task.extend(Spine.Model.Local);
        Task.active = function() {
          return this.select(function(item) {
            return !item.done;
          });
        };
        Task.done = function() {
          return this.select(function(item) {
            return !!item.done;
          });
        };
        Task.destroyDone = function() {
          var rec, _i, _len, _ref, _results;
          _ref = this.done();
          _results = [];
          for (_i = 0, _len = _ref.length; _i < _len; _i++) {
            rec = _ref[_i];
            _results.push(rec.destroy());
          }
          return _results;
        };
        return Task;
      })();
      Tasks = (function() {
        __extends(Tasks, Spine.Controller);
        Tasks.prototype.events = {
          "change   input[type=checkbox]": "toggle",
          "click    .destroy": "remove",
          "dblclick .view": "edit",
          "keypress input[type=text]": "blurOnEnter",
          "blur     input[type=text]": "close"
        };
        Tasks.prototype.elements = {
          "input[type=text]": "input"
        };
        function Tasks() {
          this.render = __bind(this.render, this);      Tasks.__super__.constructor.apply(this, arguments);
          this.item.bind("update", this.render);
          this.item.bind("destroy", __bind(function() {
            return this.trigger('destroy');
          }, this));
        }
        Tasks.prototype.render = function() {
          this.replace($("#taskTemplate").tmpl(this.item));
          return this;
        };
        Tasks.prototype.toggle = function() {
          this.item.done = !this.item.done;
          return this.item.save();
        };
        Tasks.prototype.remove = function() {
          return this.item.destroy();
        };
        Tasks.prototype.edit = function() {
          this.el.addClass("editing");
          return this.input.focus();
        };
        Tasks.prototype.blurOnEnter = function(e) {
          if (e.keyCode === 13) {
            return e.target.blur();
          }
        };
        Tasks.prototype.close = function() {
          this.el.removeClass("editing");
          return this.item.updateAttributes({
            name: this.input.val()
          });
        };
        return Tasks;
      })();
      TaskApp = (function() {
        __extends(TaskApp, Spine.Controller);
        TaskApp.prototype.events = {
          "submit form": "create",
          "click  .clear": "clear"
        };
        TaskApp.prototype.elements = {
          ".items": "items",
          ".countVal": "count",
          ".clear": "clear",
          "form input": "input"
        };
        function TaskApp() {
          this.renderCount = __bind(this.renderCount, this);
          this.addAll = __bind(this.addAll, this);
          this.addOne = __bind(this.addOne, this);      TaskApp.__super__.constructor.apply(this, arguments);
          Task.bind("create", this.addOne);
          Task.bind("refresh", this.addAll);
          Task.bind("refresh change", this.renderCount);
          Task.fetch();
        }
        TaskApp.prototype.addOne = function(task) {
          var view;
          view = new Tasks({
            item: task
          });
          return this.items.append(view.render().el);
        };
        TaskApp.prototype.addAll = function() {
          return Task.each(this.addOne);
        };
        TaskApp.prototype.create = function(e) {
          e.preventDefault();
          Task.create({
            name: this.input.val()
          });
          return this.input.val("");
        };
        TaskApp.prototype.clear = function() {
          return Task.destroyDone();
        };
        TaskApp.prototype.renderCount = function() {
          var active, inactive;
          active = Task.active().length;
          this.count.text(active);
          inactive = Task.done().length;
          if (inactive) {
            return this.clear.show();
          } else {
            return this.clear.hide();
          }
        };
        return TaskApp;
      })();
      $(function() {
        return new TaskApp({
          el: $("#tasks")
        });
      });
    }).call(this);
    
    • +3
      Не, ну так это ведь то, что CoffeeScript нагенерил. Посмотрите реальные исходники на гитхабе, там же ссылки есть.
    • +1
      Мои глаза…
  • +4
    Мне spinejs понравился из-за того что он на coffeescript сделан. А вы в примерах использовав javascript убрали всю его фишку.
  • +4
    «Spine во многом как backbone, но не backbone. Не может всего, что может backbone, но зато он легче».

    Как по мне реализация на CoffeeScript это основная фишка Spine.

    Меня Backbone устраивает тем, что он не мешает (и в каком-то смысле, все таки, помогает) работать с underscore.js.
    • +2
      Underscore — это, если не ошибаюсь, вообще зависимость Backbone.
      • –1
        Не ошибаетесь. Но использовать его например вместе с Javascript MVC прямо скажем неудобно.
  • +1
    Вот бы еще почитать статей про Ember.js. Я имею в виду — лучшие практики, а не теорию.
    • 0
      Да, было бы неплохо. Linkedin, кстати, делал анализ кучи MVC-решений и выбрали в итоге именно Ember.
  • 0
    Документация у Spine.js — катастрофа. Она в дичайших деталях объясняет все подряд, а общего и простого представления не дает.

    Когда только про него начал читать схватился за голову. А когда перешел к реальному использованию понял, что все чрезвычайно просто и благодаря небольшому объему кода легко прямо в исходниках смотреть что где и как происходит.
  • 0
    Прошу прощения, но кажется в Backbone нет контроллеров, или что то путаю?
    • 0
      Хоть х… назови, главное облизывай)
      Там контроллеры в обычном понимании называются представлениями.

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