Skip to content

Instantly share code, notes, and snippets.

@JasonOffutt
Created April 30, 2012 05:51
Show Gist options
  • Save JasonOffutt/2555853 to your computer and use it in GitHub Desktop.
Save JasonOffutt/2555853 to your computer and use it in GitHub Desktop.
Opinionated Backbone MVP Implementation via blogging engine
// Models will house domain specific functionality. They should know how to do thing that are
// within their domain, authorize and validate themselves. Application logic, however, ought
// to be pushed out to the Presenter.
var BlogPost = Backbone.Model.extend({
initialize: function(options) {
this.set({ foo: 'bar' }, { silent: true });
},
// Per Backbone's docs, `validate` should only return something if there is a validation error.
// If so, I've found it useful to return an array of hashes with the key equaling the property
// that threw an error. That way it can later be accessed via indexer.
// `validate is` fired when you call `set`, `save` and `isValid` on a model. If it fails, it will
// generally fail silently. So there's no need to wrap it in an `if` check... see `save` method
// presenter for example usage.
validate: function(options) {
this.modelErrors = null;
var errors = [];
if (typeof options.content !== 'undefined' && !options.content) {
errors.push({ content: 'Post content is required.' });
}
if (typeof options.title !== 'undefined' && !options.title) {
errors.push({ title: 'Post title is required.' });
}
if (errors.length > 0) {
this.modelErrors = errrors;
return errors;
}
}
});
var BlogPostCollection = Backbone.Model.extend({
model: SomeModel,
// Backbone will automatically sort a collection based on the result of this function.
// Alternatively, there are other sort methods that get delegated through to Underscore.
comparator: function(model) {
// Sort posts descending...
var date = model.get('dateCreated') || new Date(0);
return -date.getTime();
},
// Part of Backbone's server side magic comes from informing your models and collections
// of where they originate from. By setting the base url, we can just call `fetch()` on our
// models or collections, and Backbone will issue a `GET` request to that URL. Additionally,
// when we call things like `save()` and `destroy()` on models, Backbone will by default
// make a RESTful call with this URL as its base (e.g. - DELETE /posts/:id)
url: function() {
return '/posts';
}
});
// Using the notion of a Presenter rather than a Controller. Doing things this way allows us to
// completely decouple views from the DOM. As long as the Presenter has a reference to jQuery,
// it will be testable independently from views.
var BlogPresenter = function(options) {
this.ev = options.ev;
this.$container = $('#blogContainer');
// Bind all events in here...
// Events bound to aggregator are namespaced on type and action (e.g. - 'foo:save').
// This becomes super helpful when you've got several different Model types to worry
// about binding events to.
_.bindAll(this);
this.ev.on('post:list', this.index);
this.ev.on('post:view', this.showPost);
this.ev.on('post:delete', this.deletePost);
this.ev.on('post:edit', this.editPost);
this.ev.on('post:save', this.savePost);
};
// Presents a uniform function for injecting Views into the DOM. This allows us to manage
// DOM manipulation in a single place.
BlogPresenter.prototype.showView = function(view) {
if (this.currentView) {
// Call `close()` on the current view to clean up memory. Removes elements from DOM
// and will unbind any event listeners on said DOM elements and references to Models
// or Collections that are currently loaded in memory.
this.currentView.close();
}
this.currentView = view;
this.$container.append(this.currentView.render().$el);
};
BlogPresenter.prototype.showIndex = function() {
var listView = new ListView({ ev: this.ev, model: this.model });
this.showView(listView);
};
BlogPresenter.prototype.showPost = function(id) {
var model = this.model.get(id);
var detailsView = new DetailsView({ ev: this.ev, model: model });
this.showView(detailsView);
};
BlogPresenter.prototype.deletePost = function(id) {
var post = this.model.get(id),
promise = post.destroy();
promise.done(function() {
this.ev.trigger('post:destroyed');
});
};
BlogPresenter.prototype.editPost = function(id) {
var post = this.model.get(id),
editView = new EditView({ ev: this.ev, model: post });
this.showView(editView);
};
BlogPresenter.prototype.savePost = function(attrs, post) {
// `save` will first call `validate`. If `validate` is successful, it will call
// `Backbone.sync`, which returns a jQuery promise, that can be used to bind callbacks
// to fire additional events when the operation completes.
var promise = post.save(attrs);
if (promise) {
promise.done(function() {
// Do something now that the save is complete
});
} else {
}
}
// This is a router and only a router. Using it as a Controller generally leads to problems
// in a larger scale app. If you delegate handling actual application logic to a Presenter
// or a 'real' Controller object, you will generally have an easier time keeping things strait
// as your project grows and/or requirements change.
var BlogRouter = Backbone.Router.extend({
routes: {
'': 'index',
'post/:id': 'post',
'post/:id/edit': 'edit',
'*options': 'index' // Catchall route
},
initialize: function(options) {
this.ev = options.ev;
this.presenter = new BlogPresenter({ ev: this.ev, model: this.model });
// Listen for these post events and update URL/browser history accordingly
_.bindAll(this);
this.ev.on('post:list post:view post:edit', this.navigateTo);
},
index: function() {
this.presenter.showIndex();
},
post: function(id) {
this.presenter.showPost(id);
},
edit: function(id) {
this.presenter.showEdit(id);
},
navigateTo: function(id, uri) {
// Update the URL hash and browser history
this.navigate(uri, true);
}
});
$(function() {
// Use an event aggregator here. Passing it around can be a pain, but it keeps things neatly modular.
// In theory, we could have multiple event aggregators for different feature sets and avoid event collisions.
var eventAggregator = _.extend({}, Backbone.Events),
// New up and call fetch on a BlogPostCollection to load its posts from the server.
posts = new BlogPostCollection(),
promise = posts.fetch(),
router;
// When the AJAX request comes back from the server, load up the router and begin tracking history
promise.done(function() {
router = new BlogRouter({ ev: eventAggregator, model: posts });
Backbone.history.start();
});
});
var DetailsView = Backbone.View.extend({
tagName: 'article',
className: 'post',
template: 'blogPost',
events: {
'click .edit': 'editPost',
'click .delete': 'deletePost'
},
initialize: function(options) {
this.ev = options.ev;
},
render: function() {
var that = this;
TemplateManager.get(this.template, function(tmp) {
var html = _.template($(this.template).html(), that.model.toJSON());
that.$el.html(html);
});
return this;
},
editPost: function(e) {
var href = $(e.currentTarget).attr('href');
this.ev.trigger('post:edit', this.model.get('id'), href);
return false;
},
deletePost: function() {
if (confirm('Are you sure you want to delete "' + this.model.get('title') + '"?')) {
this.ev.trigger('post:delete', this.model.get('id'));
}
return false;
}
});
var EditView = Backbone.View.extend({
tagName: 'section',
className: 'post',
template: 'editPost',
events: {
'click .save': 'saveClicked',
'click .cancel': 'cancelClicked'
},
initialize: function(options) {
this.ev = options.ev;
_.bindAll(this);
},
render: function() {
var that = this;
TemplateManager.get(this.template, function(tmp) {
var html = _.template(tmp, that.model.toJSON());
that.$el.html(html);
});
return this;
},
saveClicked: function(e) {
var attrs = {
title = this.$el.find('#title').val(),
content = this.$el.find('#content').val(),
postDate = this.$el.find('#postDate').val()
};
this.el.trigger('post:save', attrs, this.model);
return false;
},
cancelClicked: function(e) {
this.el.trigger('post:list');
return false;
}
});
var ListView = Backbone.View.extend({
tagName: 'section',
className: 'posts',
template: '#blog',
initialize: function(options) {
this.ev = options.ev;
this.childViews = [];
this.model.forEach(function(post) {
childViews.push(new SummaryView(ev: this.ev, model: post));
});
},
render: function() {
var html = _.template($(this.template).html(), this.model.toJSON());
this.$el.html(html);
_.forEach(this.childViews, function(view) {
view.render();
});
return this;
},
onClose: function() {
_.forEach(this.childViews, function(view) {
view.close();
});
}
});
// Summary view of a blog post. Title, excerpt, author, date, etc...
var SummaryView = Backbone.View.extend({
tagName: 'li',
className: 'post',
template: 'postSummary',
events: {
'click .view': 'viewPost'
},
initialize: function(options) {
this.ev = options.ev;
_.bindAll(this);
this.model.on('change', this.onUpdated);
this.model.on('remove', this.close);
},
// Note that in `render`, we're NOT actually injecting the view's contents into the DOM.
// That will be handled by our presenter.
// The benefit of this approach is that the view is now decoupled from the DOM
render: function() {
// Use template loader to do this part. This can come in handy if you need to load up
// `n` Summary views nested inside a List view. TemplateManager and Traffic Cop will
// throttle the amount of traffic that's actually sent to the server, and provide a
// boost in performance.
var that = this;
TemplateManager.get(this.template, function(tmp) {
var html = _.template(tmp, that.model.toJSON());
that.$el.html(html);
});
return this;
},
viewPost: function(e) {
var href = $(e.currentTarget).attr('href');
this.ev.trigger('post:view', this.model.get('id'), href);
return false;
},
onUpdated: function() {
// Re-render the view when the model's state changes
this.render();
},
onClose: function() {
// Unbind events from model on close to prevent memory leaks.
this.model.off('change destroy');
}
});
// Add a `close` utility method to Backbone.View to serve as a wrapper for `remove`, and `unbind`.
// This allows a view to clean up after itself by removing its DOM elements, unbind any events, and
// call an `onClose` method in case any additional cleanup needs to happen (e.g. - unbinding any
// events explicitly bound to the model or event aggregator).
Backbone.View.prototype.close = function() {
this.remove();
this.unbind();
if (typeof this.onClose === 'function') {
this.onClose.call(this);
}
}
// Traffic Cop jQuery plugin to marshall requests being sent to the server.
// (found here: https://github.com/ifandelse/TrafficCop)
// You can optionally modify `Backbone.sync` to use this plugin over `$.ajax`
// or just use it for other utility functions (bootstrapping data, loading
// external Underscore/Mustache/Handlebars templates, etc.
// Requests are cached in here by settings value. If the cached request already
// exists, append the callback to the cached request rather than making a second
// one. This will prevent race conditions when loading things rapid fire from
// the server.
var inProgress = {};
$.trafficCop = function(url, options) {
var reqOptions = url,
key;
if(arguments.length === 2) {
reqOptions = $.extend(true, options, { url: url });
}
key = JSON.stringify(reqOptions);
if (key in inProgress) {
for (var i in {success: 1, error: 1, complete: 1}) {
inProgress[key][i](reqOptions[i]);
}
} else {
// Ultimately, we just wrap `$.ajax` and return the promise it generates.
inProgress[key] = $.ajax(reqOptions).always(function () { delete inProgress[key]; });
}
return inProgress[key];
};
// Template Manager object to handle dynamically loading templates from the server.
// Depending on whether or not the templating lib of choice supports pre-compiling them
// before they get cached, this can be a big performance booster over something like
// LAB.js or Require.js.
var TemplateManager = {
templates: {},
get: function(id, callback) {
// If the template is already in the cache, just return it.
if (this.tempaltes[id]) {
return callback.call(this, this.templates[id]);
}
// Otherwise, use Traffic Cop to load up the template.
var url = '/templates/' + id + '.html',
promise = $.trafficCop(url),
that = this;
promise.done(function(template) {
// Once loading is complete, cache the template. Optionally,
// if it's supported by the templating engine, you can pre-compile
// the template before it gets cached.
that.templates[id] = template;
callback.call(that, template);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment