Comments:"writings/published/understanding-backbone.md at master · kjbekkelund/writings · GitHub"
URL:https://github.com/kjbekkelund/writings/blob/master/published/understanding-backbone.md
Step by step from jQuery to Backbone
I've seen many struggle when they first meetBackbone.js. In this blog post I will gradually refactor a bit of code from how I used to write JavaScript before, into proper Backbone.js code using models, collections, views and events. Hopefully this process will give you a firm understanding of the core abstractions in Backbone.js.
Let's start with the code we're going to work with throughout this blog post:
$(document).ready(function(){$('#new-status form').submit(function(e){e.preventDefault();$.ajax({url:'/status',type:'POST',dataType:'json',data:{text:$('#new-status').find('textarea').val()},success:function(data){$('#statuses').append('<li>'+data.text+'</li>');$('#new-status').find('textarea').val('');}});});});
And here's the application up and running:Monologue. This simple application is based on agreat JavaScript presentation by Brandon Keepers. However, while his focus is primarily on testing and design patterns — of which he does an amazing job — mine is on explaining the core Backbone.js abstractions one step at a time.
Basically, the app lets you to write some text, click on "Post", and see what you've written show up in the list while the textarea is prepared for new input. Translating this to code, we start by waiting for the DOM to load, set a submit listener, and when the form is submitted we send the input to the server, append the response to the list of statuses and reset the input.
But, what's the problem? This code does a lot of stuff at the same time. It listens for page events, user events, network events, it does network IO, handles user input, parses the response, and does HTML templating. All in 16 lines of code. It looks like most JavaScript code I wrote a year ago. Throughout this blog post I will gradually work my way to code that follows the single responsibility principle, and which is far easier to test, maintain, reuse and extend.
There are three things we want to achieve:
- We want to move as much as possible out of
$(document).ready
, so we can trigger the code ourselves — we only want to bootstrap the application when the DOM is ready. In its current state the code is nearly impossible to test. - We want to adhere to the single responsibility principle, and make the code more reusable and easier to test.
- We want to break the coupling between the DOM and Ajax.
Separating DOM and Ajax
We start with splitting Ajax and DOM from each other, and the first step
is to create an addStatus
function:
+function addStatus(options) {+ $.ajax({+ url: '/status',+ type: 'POST',+ dataType: 'json',+ data: { text: $('#new-status textarea').val() },+ success: function(data) {+ $('#statuses ul').append('<li>' + data.text + '</li>');+ $('#new-status textarea').val('');+ }+ });+}+ $(document).ready(function() { $('#new-status form').submit(function(e) { e.preventDefault();- $.ajax({- url: '/status',- type: 'POST',- dataType: 'json',- data: { text: $('#new-status textarea').val() },- success: function(data) {- $('#statuses ul').append('<li>' + data.text + '</li>');- $('#new-status textarea').val('');- }- });+ addStatus(); }); });
However, both within data
and success
we work with the DOM. We can
break this coupling by sending these as arguments to addStatus
:
function addStatus(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json',- data: { text: $('#new-status textarea').val() },- success: function(data) {- $('#statuses ul').append('<li>' + data.text + '</li>');- $('#new-status textarea').val('');- }+ data: { text: options.text },+ success: options.success }); } $(document).ready(function() { $('#new-status form').submit(function(e) { e.preventDefault();- addStatus();+ addStatus({+ text: $('#new-status textarea').val(),+ success: function(data) {+ $('#statuses ul').append('<li>' + data.text + '</li>');+ $('#new-status textarea').val('');+ }+ }); }); });
However, I want to wrap these statuses in an object and be able to writestatuses.add
instead of addStatus
. To achieve this we can use theconstructor pattern with prototypes
to introduce a Statuses "class":
-function addStatus(options) {+var Statuses = function() {+};+Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success });-}+}; $(document).ready(function() {+ var statuses = new Statuses();+ $('#new-status form').submit(function(e) { e.preventDefault();- addStatus({+ statuses.add({ text: $('#new-status textarea').val(), success: function(data) { $('#statuses ul').append('<li>' + data.text + '</li>'); $('#new-status textarea').val(''); } }); }); });
Creating a view
Our submit handler now has one dependency, the statuses
variable, and
everything else within it is focused on the DOM. Let's move the submit
handler and everything inside it into its own class, NewStatusView
:
var Statuses = function() { }; Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success }); };+var NewStatusView = function(options) {+ var statuses = options.statuses;++ $('#new-status form').submit(function(e) {+ e.preventDefault();++ statuses.add({+ text: $('#new-status textarea').val(),+ success: function(data) {+ $('#statuses ul').append('<li>' + data.text + '</li>');+ $('#new-status textarea').val('');+ }+ });+ });+};+ $(document).ready(function() { var statuses = new Statuses();- $('#new-status form').submit(function(e) {- e.preventDefault();-- statuses.add({- text: $('#new-status textarea').val(),- success: function(data) {- $('#statuses ul').append('<li>' + data.text + '</li>');- $('#new-status textarea').val('');- }- });- });+ new NewStatusView({ statuses: statuses }); });
Now we only bootstrap our application when the DOM is loaded, and
everything else is moved out of $(document).ready
. The steps we have
taken so far has given us two components which are easier to test and
have more well-defined responsibilities. However, there is still much to clean
up. Let's start by splitting the submit handler in NewStatusView
into
its own addStatus
method:
var Statuses = function() { }; Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success }); }; var NewStatusView = function(options) {- var statuses = options.statuses;+ this.statuses = options.statuses;- $('#new-status form').submit(function(e) {- e.preventDefault();- statuses.add({- text: $('#new-status textarea').val(),- success: function(data) {- $('#statuses ul').append('<li>' + data.text + '</li>');- $('#new-status textarea').val('');- }- });- });+ $('#new-status form').submit(this.addStatus); };+NewStatusView.prototype.addStatus = function(e) {+ e.preventDefault();++ this.statuses.add({+ text: $('#new-status textarea').val(),+ success: function(data) {+ $('#statuses ul').append('<li>' + data.text + '</li>');+ $('#new-status textarea').val('');+ }+ });+}; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); });
Running this in Chrome we get this error:
Uncaught TypeError: Cannot call method 'add' of undefined
We get this error as this
means different things in the constructor
and the addStatus
method, as it is jQuery that actually calls the
latter when the user submits the form. (If you don't fully grasp howthis
works, I recommend readingUnderstanding JavaScript Function Invocation and “this”.)
To solve this problem we can use$.proxy
, which creates a
function where this
is always the same — the context you specify as
the second argument.
var Statuses = function() { }; Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success }); }; var NewStatusView = function(options) { this.statuses = options.statuses;- $('#new-status form').submit(this.addStatus);+ var add = $.proxy(this.addStatus, this);+ $('#new-status form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault(); this.statuses.add({ text: $('#new-status textarea').val(), success: function(data) { $('#statuses ul').append('<li>' + data.text + '</li>'); $('#new-status textarea').val(''); } }); }; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); });
Let's make the success
callback call methods instead of working
directly on the DOM, which makes the callback easier to read and work
with:
var Statuses = function() { }; Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success }); }; var NewStatusView = function(options) { this.statuses = options.statuses; var add = $.proxy(this.addStatus, this); $('#new-status form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault();+ var that = this;+ this.statuses.add({ text: $('#new-status textarea').val(), success: function(data) {- $('#statuses ul').append('<li>' + data.text + '</li>');- $('#new-status textarea').val('');+ that.appendStatus(data.text);+ that.clearInput(); } }); };+NewStatusView.prototype.appendStatus = function(text) {+ $('#statuses ul').append('<li>' + text + '</li>');+};+NewStatusView.prototype.clearInput = function() {+ $('#new-status textarea').val('');+}; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); });
This is much easier to test and to work with as our code grows. We are also far on our way to understand Backbone.js.
Adding events
For the next step we need to introduce our first bit of Backbone:
events. Events are basically just a way to say: "Hi, I want to know when
some action occurs" and "Hi, you know what? The action you're waiting
for just occurred!" We are used to this idea from jQuery DOM events such
as listening for click
and submit
, e.g.
$('form').bind('submit',function(){alert('User submitted form');});
The Backbone.js documentation describesBackbone.Events
as follows: "Events
is a module that can be mixed in to any object, giving the object the
ability to bind and trigger custom named events." The docs also shows
us how we can use Underscore.js to create an
event dispatcher, i.e. a component on which we can bind and trigger
events:
varevents=_.clone(Backbone.Events);
With this little bit of functionality we can let the success
callback
trigger an event instead of calling methods. We can also declare in the
constructor which methods we want to call when the event is triggered:
+var events = _.clone(Backbone.Events);+ var Statuses = function() { }; Statuses.prototype.add = function(options) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: options.text }, success: options.success }); }; var NewStatusView = function(options) { this.statuses = options.statuses;+ events.on('status:add', this.appendStatus, this);+ events.on('status:add', this.clearInput, this);+ var add = $.proxy(this.addStatus, this); $('#new-status form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault();- var that = this;- this.statuses.add({ text: $('#new-status textarea').val(), success: function(data) {- that.appendStatus(data.text);- that.clearInput();+ events.trigger('status:add', data.text); } }); }; NewStatusView.prototype.appendStatus = function(text) { $('#statuses ul').append('<li>' + text + '</li>'); }; NewStatusView.prototype.clearInput = function() { $('#new-status textarea').val(''); }; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); });
Now we can declare in the constructor what we want to happen when a
status is added, instead of addStatus
being responsible for handling
success. The only responsibility addStatus
should have is backend
communication, not updating the DOM.
As we no longer deal with the view in the success
callback we can move
the triggering of the event into the add
method on Statuses
:
var events = _.clone(Backbone.Events); var Statuses = function() { };-Statuses.prototype.add = function(options) {+Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json',- data: { text: options.text },- success: options.success+ data: { text: text },+ success: function(data) {+ events.trigger('status:add', data.text);+ } }); }; var NewStatusView = function(options) { this.statuses = options.statuses; events.on('status:add', this.appendStatus, this); events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); $('#new-status form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault();- this.statuses.add({- text: $('#new-status textarea').val(),- success: function(data) {- events.trigger('status:add', data.text);- }- });+ this.statuses.add($('#new-status textarea').val()); }; NewStatusView.prototype.appendStatus = function(text) { $('#statuses ul').append('<li>' + text + '</li>'); }; NewStatusView.prototype.clearInput = function() { $('#new-status textarea').val(''); }; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); });
A view's responsibilities
Looking at appendStatus
and clearInput
in NewStatusView
, we see
that these methods focus on two different DOM elements, #statuses
and#new-status
, respectively. I've given them different colors in theapp, so you can see the
difference. Working on two elements in a view does not adhere to the
principles I outline in my blog post ona view's responsibilities.
Let's pull a StatusesView
out of NewStatusView
, and let it be
responsible for #statuses
. Separating these responsibilities is
especially simple now that we use events — with a regular success
callback this would be far more difficult.
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = function(options) { this.statuses = options.statuses;- events.on('status:add', this.appendStatus, this); events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); $('#new-status form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault(); this.statuses.add($('#new-status textarea').val()); };-NewStatusView.prototype.appendStatus = function(text) {- $('#statuses ul').append('<li>' + text + '</li>');-}; NewStatusView.prototype.clearInput = function() { $('#new-status textarea').val(''); };+var StatusesView = function() {+ events.on('status:add', this.appendStatus, this);+};+StatusesView.prototype.appendStatus = function(text) {+ $('#statuses ul').append('<li>' + text + '</li>');+};+ $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses });+ new StatusesView(); });
As each view is now responsible for only one HTML element, we can specify them when instantiating the views:
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = function(options) { this.statuses = options.statuses;+ this.el = $('#new-status'); events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this);- $('#new-status form').submit(add);+ this.el.find('form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault();- this.statuses.add($('#new-status textarea').val());+ this.statuses.add(this.el.find('textarea').val()); }; NewStatusView.prototype.clearInput = function() {- $('#new-status textarea').val('');+ this.el.find('textarea').val(''); }; var StatusesView = function() {+ this.el = $('#statuses');+ events.on('status:add', this.appendStatus, this); }; StatusesView.prototype.appendStatus = function(text) {- $('#statuses ul').append('<li>' + text + '</li>');+ this.el.find('ul').append('<li>' + text + '</li>'); }; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ statuses: statuses }); new StatusesView(); });
Our views, NewStatusView
and StatusesView
are still difficult to
test because they depend on having the HTML present, e.g. in order to
find $('#statuses')
. To remedy this we can pass in its DOM
dependencies when we instantiate a view.
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = function(options) { this.statuses = options.statuses;- this.el = $('#new-status');+ this.el = options.el; events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); this.el.find('form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault(); this.statuses.add(this.el.find('textarea').val()); }; NewStatusView.prototype.clearInput = function() { this.el.find('textarea').val(''); };-var StatusesView = function() {- this.el = $('#statuses');+var StatusesView = function(options) {+ this.el = options.el; events.on('status:add', this.appendStatus, this); }; StatusesView.prototype.appendStatus = function(text) { this.el.find('ul').append('<li>' + text + '</li>'); }; $(document).ready(function() { var statuses = new Statuses();- new NewStatusView({ statuses: statuses });- new StatusesView();+ new NewStatusView({ el: $('#new-status'), statuses: statuses });+ new StatusesView({ el: $('#statuses') }); });
Now, this is easy to test! With this change we can use ajQuery trick to test our views.
Instead of initiating our views by passing in for example$('#new-status')
, we can pass in the necessary HTML wrapped in jQuery,
e.g. $('<div><form>…</form></div>')
. jQuery will then create the
needed DOM elements on the fly. This ensures blazingly fast tests — on
my current project our nearly 200 tests run in less than one second.
Our next step is introducing a helper to clean up our views a little
bit. Instead of writing this.el.find
we can create a simple helper so
we can write this.$
instead. With this little change it feels like we
are saying, I want to use jQuery to look for something locally on this
view instead of globally in the entire HTML. And it's so easy to add:
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = function(options) { this.statuses = options.statuses; this.el = options.el; events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this);- this.el.find('form').submit(add);+ this.$('form').submit(add); }; NewStatusView.prototype.addStatus = function(e) { e.preventDefault();- this.statuses.add(this.el.find('textarea').val());+ this.statuses.add(this.$('textarea').val()); }; NewStatusView.prototype.clearInput = function() {- this.el.find('textarea').val('');+ this.$('textarea').val(''); };+NewStatusView.prototype.$ = function(selector) {+ return this.el.find(selector);+}; var StatusesView = function(options) { this.el = options.el; events.on('status:add', this.appendStatus, this); }; StatusesView.prototype.appendStatus = function(text) {- this.el.find('ul').append('<li>' + text + '</li>');+ this.$('ul').append('<li>' + text + '</li>'); };+StatusesView.prototype.$ = function(selector) {+ return this.el.find(selector);+}; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses') }); });
However, adding this functionality for every view is a pain. That's one of the reasons to use Backbone.js views— reusing functionality across views.
Getting started with views in Backbone
With the current state of our code, it's just a couple of lines of change needed to add Backbone.js views:
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); };-var NewStatusView = function(options) {- this.statuses = options.statuses;- this.el = options.el;-- events.on('status:add', this.clearInput, this);-- var add = $.proxy(this.addStatus, this);- this.$('form').submit(add);-};+var NewStatusView = Backbone.View.extend({+ initialize: function(options) {+ this.statuses = options.statuses;+ this.el = options.el;++ events.on('status:add', this.clearInput, this);++ var add = $.proxy(this.addStatus, this);+ this.$('form').submit(add);+ }+}); NewStatusView.prototype.addStatus = function(e) { e.preventDefault(); this.statuses.add(this.$('textarea').val()); }; NewStatusView.prototype.clearInput = function() { this.$('textarea').val(''); }; NewStatusView.prototype.$ = function(selector) { return this.el.find(selector); }; var StatusesView = function(options) { this.el = options.el; events.on('status:add', this.appendStatus, this); }; StatusesView.prototype.appendStatus = function(text) { this.$('ul').append('<li>' + text + '</li>'); }; StatusesView.prototype.$ = function(selector) { return this.el.find(selector); }; $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses') }); });
As you can see from the code, we use Backbone.View.extend
to create a
new view class in Backbone. Within extend
we can specify instance
methods such as initialize
, which is the name of the constructor.
Now that we have started the move over to Backbone.js views, let's go on and move both views fully over:
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses; this.el = options.el; events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add);- }+ },++ addStatus: function(e) {+ e.preventDefault();++ this.statuses.add(this.$('textarea').val());+ },++ clearInput: function() {+ this.$('textarea').val('');+ },++ $: function(selector) {+ return this.el.find(selector);+ } });-NewStatusView.prototype.addStatus = function(e) {- e.preventDefault();-- this.statuses.add(this.$('textarea').val());-};-NewStatusView.prototype.clearInput = function() {- this.$('textarea').val('');-};-NewStatusView.prototype.$ = function(selector) {- return this.el.find(selector);-};-var StatusesView = function(options) {- this.el = options.el;-- events.on('status:add', this.appendStatus, this);-};-StatusesView.prototype.appendStatus = function(text) {- this.$('ul').append('<li>' + text + '</li>');-};-StatusesView.prototype.$ = function(selector) {- return this.el.find(selector);-};+var StatusesView = Backbone.View.extend({+ initialize: function(options) {+ this.el = options.el;++ events.on('status:add', this.appendStatus, this);+ },++ appendStatus: function(text) {+ this.$('ul').append('<li>' + text + '</li>');+ },++ $: function(selector) {+ return this.el.find(selector);+ }+}); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses') }); });
Now that we use Backbone.js views we can remove the this.$
helper, as it
already exists in Backbone. We also no longer need to set this.el
ourselves, as Backbone.js does it automatically when a view is instantiated
with an HTML element.
var events = _.clone(Backbone.Events); var Statuses = function() { }; Statuses.prototype.add = function(text) { $.ajax({ url: '/status', type: 'POST', dataType: 'json', data: { text: text }, success: function(data) { events.trigger('status:add', data.text); } }); }; var NewStatusView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses;- this.el = options.el; events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault(); this.statuses.add(this.$('textarea').val()); }, clearInput: function() { this.$('textarea').val(''); },-- $: function(selector) {- return this.el.find(selector);- } }); var StatusesView = Backbone.View.extend({ initialize: function(options) {- this.el = options.el;- events.on('status:add', this.appendStatus, this); }, appendStatus: function(text) { this.$('ul').append('<li>' + text + '</li>'); },-- $: function(selector) {- return this.el.find(selector);- } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses') }); });
Let's use a model
The next step is introducing models, which are responsible for the
network traffic, i.e. Ajax requests and responses. As Backbone nicely
abstracts Ajax, we don't need to specify type
, dataType
and data
anymore. Now we only need to specify the URL and call save
on the
model. The save
method accepts the data we want to save as the first
parameter, and options, such as the success
callback, as the second
parameter.
var events = _.clone(Backbone.Events);+var Status = Backbone.Model.extend({+ url: '/status'+});+ var Statuses = function() { }; Statuses.prototype.add = function(text) {- $.ajax({- url: '/status',- type: 'POST',- dataType: 'json',- data: { text: text },- success: function(data) {- events.trigger('status:add', data.text);- }- });+ var status = new Status();+ status.save({ text: text }, {+ success: function(model, data) {+ events.trigger('status:add', data.text);+ }+ }); }; var NewStatusView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses; events.on('status:add', this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault(); this.statuses.add(this.$('textarea').val()); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({ initialize: function(options) { events.on('status:add', this.appendStatus, this); }, appendStatus: function(text) { this.$('ul').append('<li>' + text + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses') }); });
Handling several models
Now that we have introduced models, we need a concept for a list of models, such as the list of statuses in our application. In Backbone.js this concept is called a collection.
One really cool thing about collections is that they have scoped events.
Basically, this just means that we can bind and trigger events directly
on a collection instead of using our events
variable — our events will
live on statuses
instead of events
. As we now start firing events
directly on statuses there's no need for "status" in the event name, so
we rename it from "status:add" to "add".
-var events = _.clone(Backbone.Events);- var Status = Backbone.Model.extend({ url: '/status' });-var Statuses = function() {-};-Statuses.prototype.add = function(text) {- var status = new Status();- status.save({ text: text }, {- success: function(model, data) {- events.trigger("status:add", data.text);- }- });-};+var Statuses = Backbone.Collection.extend({+ add: function(text) {+ var that = this;+ var status = new Status();+ status.save({ text: text }, {+ success: function(model, data) {+ that.trigger("add", data.text);+ }+ });+ }+}); var NewStatusView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses;- events.on("status:add", this.clearInput, this);+ this.statuses.on("add", this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault(); this.statuses.add(this.$('textarea').val()); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({ initialize: function(options) {+ this.statuses = options.statuses;+- events.on("status:add", this.appendStatus, this);+ this.statuses.on("add", this.appendStatus, this); }, appendStatus: function(text) { this.$('ul').append('<li>' + text + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses });- new StatusesView({ el: $('#statuses') });+ new StatusesView({ el: $('#statuses'), statuses: statuses }); });
We can simplify this even more by using Backbone's create
method. It
creates a new model instance, adds it to the collection and saves it to
the server. Therefore we must specify what type of model the collection
should handle. There are two things we need to change to use Backbone
collections:
var Status = Backbone.Model.extend({ url: '/status' }); var Statuses = Backbone.Collection.extend({- add: function(text) {- var that = this;- var status = new Status();- status.save({ text: text }, {- success: function(model, data) {- that.trigger("add", data.text);- }- });- }+ model: Status }); var NewStatusView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses; this.statuses.on("add", this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault();- this.statuses.add(this.$('textarea').val());+ this.statuses.create({ text: this.$('textarea').val() }); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({ initialize: function(options) { this.statuses = options.statuses; this.statuses.on("add", this.appendStatus, this); },- appendStatus: function(text) {+ appendStatus: function(status) {- this.$('ul').append('<li>' + text + '</li>');+ this.$('ul').append('<li>' + status.get("text") + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), statuses: statuses }); new StatusesView({ el: $('#statuses'), statuses: statuses }); });
As with el
earlier, Backbone.js automatically sets this.collection
when collection
is passed. Therefore we rename statuses
tocollection
in our view:
var Status = Backbone.Model.extend({ url: '/status' }); var Statuses = Backbone.Collection.extend({ model: Status }); var NewStatusView = Backbone.View.extend({- initialize: function(options) {+ initialize: function() {- this.statuses = options.statuses;-- this.statuses.on('add', this.clearInput, this);+ this.collection.on('add', this.clearInput, this); var add = $.proxy(this.addStatus, this); this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault();- this.statuses.add({ text: this.$('textarea').val() });+ this.collection.create({ text: this.$('textarea').val() }); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({- initialize: function(options) {+ initialize: function() {- this.statuses = options.statuses;-- this.statuses.on('add', this.appendStatus, this);+ this.collection.on('add', this.appendStatus, this); }, appendStatus: function(status) { this.$('ul').append('<li>' + status.get('text') + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses();- new NewStatusView({ el: $('#new-status'), statuses: statuses });+ new NewStatusView({ el: $('#new-status'), collection: statuses });- new StatusesView({ el: $('#statuses'), statuses: statuses });+ new StatusesView({ el: $('#statuses'), collection: statuses }); });
Evented views
Now, let's get rid of that nasty $.proxy
stuff. We can do this by
letting Backbone.js delegate our events
by specifying them in an events
hash in the view. This hash is of the
format {"event selector": "callback"}
:
var Status = Backbone.Model.extend({ url: '/status' }); var Statuses = Backbone.Collection.extend({ model: Status }); var NewStatusView = Backbone.View.extend({+ events: {+ 'submit form': 'addStatus'+ },+ initialize: function() { this.collection.on('add', this.clearInput, this);-- var add = $.proxy(this.addStatus, this);- this.$('form').submit(add); }, addStatus: function(e) { e.preventDefault(); this.collection.create({ text: this.$('textarea').val() }); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({ initialize: function() { this.collection.on('add', this.appendStatus, this); }, appendStatus: function(status) { this.$('ul').append('<li>' + status.get('text') + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), collection: statuses }); new StatusesView({ el: $('#statuses'), collection: statuses }); });
Escape it!
As our last step we're going to prevent XSS-exploits. Instead of usingmodel.get('text')
let's use thebuilt-in escape handling and
write model.escape('text')
instead. If you're usingHandlebars,Mustache or similar templating
engines, you might get this functionality out of the box.
var Status = Backbone.Model.extend({ url: '/status' }); var Statuses = Backbone.Collection.extend({ model: Status }); var NewStatusView = Backbone.View.extend({ events: { "submit form": "addStatus" }, initialize: function(options) { this.collection.on("add", this.clearInput, this); }, addStatus: function(e) { e.preventDefault(); this.collection.create({ text: this.$('textarea').val() }); }, clearInput: function() { this.$('textarea').val(''); } }); var StatusesView = Backbone.View.extend({ initialize: function(options) { this.collection.on("add", this.appendStatus, this); }, appendStatus: function(status) {- this.$('ul').append('<li>' + status.get("text") + '</li>');+ this.$('ul').append('<li>' + status.escape("text") + '</li>'); } }); $(document).ready(function() { var statuses = new Statuses(); new NewStatusView({ el: $('#new-status'), collection: statuses }); new StatusesView({ el: $('#statuses'), collection: statuses }); });
And we're done!
This is our final code:
varStatus=Backbone.Model.extend({url:'/status'});varStatuses=Backbone.Collection.extend({model:Status});varNewStatusView=Backbone.View.extend({events:{'submit form':'addStatus'},initialize:function(){this.collection.on('add',this.clearInput,this);},addStatus:function(e){e.preventDefault();this.collection.create({text:this.$('textarea').val()});},clearInput:function(){this.$('textarea').val('');}});varStatusesView=Backbone.View.extend({initialize:function(){this.collection.on('add',this.appendStatus,this);},appendStatus:function(status){this.$('ul').append('<li>'+status.escape('text')+'</li>');}});$(document).ready(function(){varstatuses=newStatuses();newNewStatusView({el:$('#new-status'),collection:statuses});newStatusesView({el:$('#statuses'),collection:statuses});});
And here's the application running with the refactored code. Yeah, it's still the exact same application from a user's point of view.
However, the code has increased from 16 lines to more than 40, so why do I think this is better? Because we are now working on a higher level of abstraction. This code is more maintainable, easier to reuse and extend, and easier to test. What I've seen is that Backbone.js helps improve the structure of my JavaScript applications considerably, and in my experience the end result is often less complex and has fewer lines of code than my "regular JavaScript".
Want to learn more?
By now you should understand far more of Backbone.js than when you did an hour ago. There are some great resources for learning Backbone.js out there, but there's also a whole lot of crap. Actually, theBackbone.js documentation is superb as soon as you have a better understanding of how the framework works at a higher level. Addy Osmani'sDeveloping Backbone.js Applications is another good source.
Reading theannotated source of Backbone.js is also a great way to get a better understanding of how it works. I found this to be an enlightening experience when I had used Backbone.js for some weeks. The Backbone.js example onTodoMVC is another great way to learn more.
If you want to get started with Require.js in a Backbone app, you can check outthe follow-up to this blog post.
And, lastly, if you want to get a better understanding of the creation of a framework such as Backbone.js, I recommend the magnificentJavaScript Web Applications by Alex MacCaw.