Created
April 30, 2012 05:51
-
-
Save JasonOffutt/2555853 to your computer and use it in GitHub Desktop.
Opinionated Backbone MVP Implementation via blogging engine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; | |
} | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$(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(); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
}); | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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'); | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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