The Skytap web UI is a Node.js application that’s built on both server- and client-side use of Backbone. We’re open-sourcing a number of the tools we use to glue it all together, beginning with minorjs, our base framework. Next up is minorjs-frames, which we use to manage Backbone views. If you’re familiar with backbone.layoutmanager, you’re on the right track, but, obviously, we like this better. It’s very light on dependencies, and light in general, and it’s been working out very well for us. As you look through the source, you’ll see that it does rely on a few minor conventions that we’ve built into backbone models and views.
What is a frame?
A frame is a set of Backbone views that work together to represent a shared model layer.
What problem do frames solve?
Frames address the need for composable layouts built from modular UI components.
How did we get here?
Most of the time, we’re developing pages within an app. And most of the time, these pages are representations of a resource or set of resources, which correspond to Backbone models and collections. Different views on a page represent different properties of a resource, but typically they’re all linked to the same resource.
To put it less abstractly, let’s say we have a typical index page, which, for Skytap, might mean a list of VPNs, for instance.
In a very basic implementation, this would involve a ListView
, corresponding to a VPNCollection
, that renders a set of ListItemView
s, each corresponding to a VPNModel
:
VpnListView VpnListItemView VpnListItemView VpnListItemView … VpnListItemView
This is really simple, until we want our list to be interactive. Let’s say we want to be able to sort our list of VPNs. And paginate through it. And search it.
At first glance, this seems easy too; you just add event handlers for all these actions:
class VpnListView extends ListView events: ‘submit #searchForm’ : ‘handleSearch’ ‘change #sortSelect’ : ‘handleSort’ ‘click .pagination-link’ : ‘handlePageChange’
Composition over inheritance
But of course, we want to reuse these behaviors, and possibly in different combinations. Other lists might be sortable, or paginated. We know we can’t use inheritance for this, or we’ll end up with classes like SortableSearchableListView
and PaginatedSearchableListView
and SortablePaginatedListView
.
One simple approach to this would be to mix in the event binding and event handlers into the views that need them. This is an approach we use sometimes, and have termed “behaviors”. It’s inspired by but implemented differently than Marionette’s behaviors: http://marionettejs.com/docs/marionette.behavior.html
The limitation of these behaviors is that they’re not renderable, so any view using them will have to include markup for them in its template. A “handle sort” behavior would require any view that uses it to render a <select>
element, for instance. This is not very DRY and it’s somewhat error-prone.
More pointedly, it means just about any change to our collection would require virtually the entire page to be re-rendered. And that doesn’t have to be the case.
Componentized views
If I move from page 1 to page 2 while viewing a collection of VPNs, I need to render a new set of VPNListItem
s, but I haven’t changed the attribute by which they’re being sorted. My sort dropdown won’t have changed, so I don’t need to re-render it. And if I change the attribute by which I’m sorting the list, I’ll have to re-render the list items, but I’ll still be on page 1 of 8, displaying items 1-20 of 152, so I won’t have to re-render the pagination controls.
The sort dropdown component can render in response to changes to a sort_field
attribute of the collection, while the pagination controls can render in response to changes to the count
and offset
attributes, and the list of items can render in response to changes to the collection’s membership or ordering.
And these components don’t need to know about each other. They just need to know about the same collection. This is the approach we prefer.
A few new conventions
We’ve given views names. Each view prototype has a name
attribute. This allows us to store instances of views in a lookup table when we’re managing their DOM relationships.
Models and Collection prototypes have a resource
property. For instance, a VPNModel
’s resource will be “vpns”. Similarly, this allows us to store instances in lookup tables, and it also makes URL generation easy.
Putting it all together
A frame provides an interface for initializing and rendering a set of views with the same model layer.
Here’s simple example frame:
class TemplatesIndexClientFrame extends BaseFrame renderContextKlass: RenderContextBrowser isPage: true isNewFrame: true pageUrl: '/templates' frameCollectionKlass: TemplateCollection viewKlasses: [ VpnListView SearchView SortView PaginationView ]
Pretty simple.
There are a few configuration variables, which I’ll only discuss briefly:
renderContextKlass
: At Skytap, we use Backbone on both the client and the server. Browsers have the DOM, and Node does not, at least out of the box. We use a library called Cheerio (https://github.com/cheeriojs/cheerio) to assemble markup, but things work a little differently. The “render context” is a place to put specific instructions for how to render based on—you guessed it—your context, either browser or server.
isPage
: Some frames will constitute an entire page (minus things like global navigation), while others will appear inside dialogs, or in a sidebar, etc. Knowing the difference can be helpful if you’re going to do things like pushState
-based URL routing.
isNewFrame
: Since we can render pages on the server, we might have all of our markup generated by the time we instantiate a frame in the browser. isNewFrame
tells us whether we need to render our views or just instantiate them.
pageUrl
: Likewise, this can be useful for URL routing. These are hooks; things that we think will be helpful universally as you customize frames for use in your app.
The only truly important bits here are frameCollectionKlass
and viewKlasses
. The latter specifies the views that will be contained in this frame, and the former is the core model-layer object for this frame. It will be passed as collection
in the options
hash to each view’s constructor.
Let’s look inside the actual code for MinorJS Frames. I’m going to skip over some things and get to the important parts.
First, we call a method called setupResources
, which in turn calls setupFrameCollection
. A frame can have one frameCollection
and/or one frameModel
. It can also have supplementary models and collections.
setupFrameCollection: () -> if @frameCollectionKlass # We define resource on Backbone models and collections. # It’s basically just a plural, URL-friendly string. # For a VPNCollection, it would be “vpns”. collectionType = @frameCollectionKlass::resource if @isNewFrame # if this is brand new, we haven’t fetched data yet collectionData = null collectionOptions = @retrieveFilters(collectionType) else # @payload is totally optional. # if we’re already fetched data for this collection # e.g. we’re on the client and we fetched on the server # then we can store that data somewhere, # retrieve it, and assign it to @payload collectionData = @payload[collectionType] collectionOptions = @payload.listOptions collectionOptions.request = @request if @request @collections.frameCollection = new @frameCollectionKlass(collectionData, collectionOptions) else if @frameCollection @collections.frameCollection = @frameCollection @
Then we’re going to call instantiateViews
:
instantiateViews: () -> # Instantiates a flat map lookup # with view name as a key, and view # instance as value attrs = @getViewAttributes() @viewInstances = {} for klass in @viewKlasses @viewInstances[klass::name] = new klass(attrs) @
Here’s getViewAttributes:
getViewAttributes: () -> attrs = @renderContext.getViewAttributes() # references in JavaScript are cheap. # we’ll pass all the model-layer objects to all the views. # we're going to trust views to sort through this stuff in initialize() # and only take what they need attrs[if modelType is 'frameModel' then 'model' else modelType] = modelInstance for modelType, modelInstance of @models attrs[if collectionType is 'frameCollection' then 'collection' else collectionType] collectionInstance for collectionType, collectionInstance of @collections attrs
Now we’ve done our setup. The next step is to render.
Rendering
Our basic approach to rendering Backbone views is that we always specify el
on a view’s constructor, and that an element matching that selector will always be present in the document before we call render
. So a basic view looks like this:
class VPNListView extends Backbone.View el: ‘.vpn-list-container’ name: ‘vpnList’ template: () -> ‘<marquee>placeholder</marquee>’ render: () -> @$el.html @template()
So, we find an element in our document that matches the selector .vpn-list-container
, and we insert a string of HTML into it.
The drawback of this approach comes when we have nested views.
Handling nested views
Because we want our views to be totally modular, we don’t want to restrict where in a page they could appear. Perhaps a sort dropdown will appear within the markup for a list, perhaps it’ll appear above it or below it.
Since we need every view’s el
to be present in the document before we render it, and any view’s el
might be nested within the markup of another view, we must make sure we don’t try to render a nested view until after its “parent” view has been rendered. And yet we don’t want views to know about each other, as this would prevent them from being truly modular.
The “DOM dependency manager”
We solve this problem by outsourcing knowledge of the nesting of views to a utility that we call the DOM dependency manager (minorjs-dom-dependency-manager). These views are only related to each other insofar as their markup can be nested within the DOM (well, the document; we only have a DOM in the browser).
When we create a frame, we specify these nesting relationships, or “dependencies”:
class TemplatesIndexClientFrame extends BaseFrame renderContextKlass: RenderContextBrowser domDependencies: vpnList : sorting : null pagination : resultsCount : null isPage: true isNewFrame: true pageUrl: '/templates' frameCollectionKlass: TemplateCollection viewKlasses: [ VpnListView SearchView SortView PaginationView ResultsCountView FiltersView ]
We give views names to make this easier. So, the view named vpnList
has two “children”: a view named sorting
and a view named pagination
. The pagination view, in turn, has an instance of resultsCount
nested within its markup. Note that there’s another view in this frame with the constructor FiltersView
, and that it’s not mentioned in domDependencies
, because it’s not involved in any nesting relationships.
Basically, the DomDependencyManager
does two things. The first is to set up event handlers. Each view emits an event when it’s done listening (we implemented this; it’s not native to Backbone), and the DomDependencyManager
tells child views to listen to the render
event on their parents and render in response:
processDependencies: (view) -> for childName, childView of view.childViews @bindChildListeners(view.parentView, childName) @
bindChildListeners: (parentName, childName) ->
return @ unless @viewInstances[parentName]
@viewInstances[childName] .listenTo @viewInstances[parentName], 'postRender', () -> @refreshEl().render() .listenTo @viewInstances[parentName], 'removed', () -> @remove()
refreshEl
is a quick method we wrote that allows a Backbone view to renew its jQuery reference to its el
, if $el
refers to a node that’s no longer in the DOM.
The second thing that the DomDependencyManager
does is to sort views, such that, when we render the frame for the first time, we start with the outermost descendant views and work through their ancestors one level of nesting at a time:
childrenQueue = [] sortDomStructure: (views) -> # return an array of view names, sorted # such that a descendant view never appears # before any of its ancestors i = 0 childrenQueue = [] for name, childNames of views @processViewNode(name, childNames) next = childrenQueue[i] while(next) for childName, children of next @processViewNode(childName, children) i++ next = childrenQueue[i] @
processViewNode: (viewName, children) -> @views.push(viewName) if children childrenQueue.push(children) @processDependencies(parentView: viewName, childViews : children)
We’ll be open-sourcing the DomDependencyManager
as well.
Getting started with MinorJS Frames
To get started using MinorJS Frames, head over to Skytap’s Github page to find instructions on installation.
Does working with Node.js sound interesting to you? Come work with us!