Dealing with Backbone Views with dependency injection
** My examples are in Coffeescript. Sorry hardcore js nerds :/ **
As many know, working with backbone can start of exciting and quickly move to frustration. One of these pain points are Views. Specifically nested views. Typically you will have a main view that contains many other views. If your testing your views (you are testing your views, riiiight?) It becomes even more annoying to deal with views because in order to test one view, you usually have to set up a bunch of views with models and collections and more and more crazy blah lbah! waka bloody hell! Ok so it's a mess. But there is a better way. Yes, prepared to be enlighted. Welcome to Module Views (I just kind of made that up, but I think it makes sense).
Here is how it works. Lets say you have a little todo list app (everyone does) and on the main page you have a view things going on. You have:
- List of todos
- Header with user information
Your initial impression might be to create something that looks a littlie bit like this.
class IndexView extends Backbone.View
initialize: ->
@todos = new ToDoCollection
@todos.fetch()
@todos = new ToDoView @todos
@user = new User ENV.CURRENT_USER
@headerView = new HeaderView @user
render: ->
@$el.append @todos.render().el
@$el.find('.header').html @headerView.render().el
# SOME ROUTER/BUNDLE/OTHER JS FIlE
@indexView = new IndexView()
$('body').html @indexView.render().el
There are a few problems with this. First, if your are bootstrapping data on the page like I am on line 7 with ENV.CURRENTUSER, you just created a dependency on your backbone class with the source of your data. Thats bad. Also, this is now a pretty hard class to test. You need to mock out all of the fetch() requests, create fake ENV.CURRENTUSER data and THEN you can finally start your test. Seems like a big mess. A little dependency incjection can get us out of this mess.
Introducing Module Views aka (Child/Parent Views)
class IndexView extends Backbone.View
# No need to call render or initialize
# SOME ROUTER/BUNDLE/OTHER JS FIlE
current_user = new User ENV.CURRENT_USER
todos = new ToDoCollection
todos.fetch()
indexView = new IndexView
el: 'body'
views:
"#header" : new HeaderView
model: current_user
"#todos" : new ToDoView
collection: todos
indexView.render()
# note you could leave out the el: 'body' and just do
# something like $('body').html indexView.render().el
This is all very cool but I left out a part. The actual engine behind this making all of this possible. First, thank you to Ryan Florence for both comming up with this idea as well as writinging most of this code. He's a good coder and a pleasure to work with at Instructure.
# Extends Backbone.View on top of itself with some added features
# we use regularly
class Backbone.View extends Backbone.View
##
# Manages child views and renders them whenever the parent view is rendered.
# Specify views as key:value pairs of `className: view` where `className` is
# a CSS className to find the element in which to to append a rendered
# `view.el`
#
# Be sure to call `super` in the parent view's `render` method _after_ the
# html has been set.
views: false
# example: new ExampleView
##
# Define default options, options passed in to the view will overwrite these
defaults:
# can hand a view a template option to avoid subclasses that only add a
# different template
template: null
initialize: (options) ->
@options = _.extend {}, @defaults, @options, options
@setTemplate()
@$el.data 'view', this
this
setTemplate: ->
@template = @options.template if @options.template
##
# Extends render to add support for chid views and element filtering
render: (opts = {}) =>
@renderEl()
@_afterRender()
this
renderEl: ->
@$el.html @template(@toJSON()) if @template
##
# Internal afterRender
# @api private
_afterRender: ->
@cacheEls() if @els
@$('[data-bind]').each @createBinding
@afterRender()
# its important for renderViews to come last so we don't filter
# and cache all the child views elements
@renderViews() if @options.views
##
# Add behavior and bindings to elements.
afterRender: ->
##
# in charge of getting variables ready to pass to handlebars during render
# override with your own logic to do something fancy.
toJSON: ->
json = ((@model ? @collection)?.toJSON arguments...) || {}
json.cid = @cid
json
##
# Renders all child views
#
# @api private
renderViews: ->
_.each @options.views, @renderView
##
# Renders a single child view and appends its designated element
# Use ids in your view, not classes. This
#
# @api private
renderView: (view, selector) =>
target = @$("##{selector}")
target = @$(".#{selector}") unless target.length
view.setElement target
view.render()
@[selector] ?= view
Backbone.View
Ok so there is a bunch of stuff here but how to follow this is just to start with the render function. As you can see, it will call _afterRender() which then will get to renderViews. Assuming you created each of your views with a template, it will automatically call toJSON and dump that into those templates, then take the rendered view and dump it into a dom id/class/element of your choice.
** Conclusion **
By extending backbone in appropriate ways, you can make your day to day life a lot easier. Dependency Injection can make it both easier to write, undertand and test your classes and ensuring coupling doesn't exisit helps to reduce crazy code flying all over the place.
I'm open to thoughts and can post this in a repository if you would like.
- Sterling
UPDATE Jan 29, 2013 - Here is a GIST of the code since coderwall sucks a formatting code. Ironic that a whole service around sharing code doesn't format code right. haha
Written by Sterling Cobb
Related protips
3 Responses
This looks good, can you put this up on gist? I really dislike how code looks on coderwall — the one thing that really bugs me about this service.
@jeroen_ransijn Sure I'll post a link at the bottom of this post. .... one second....
Hey I wrote a small library that is in the same vibe, tell me what you think! http://spacenick.github.com/backbone-baguette/#CompositeView