Skip to content

A Simple But Powerful Backbone View Model Binder : Javascript Weekly April 13th.

fejustin edited this page Jul 16, 2012 · 14 revisions

Backbone is a great platform for writing thick client pages/applications but I've found that as Views grow in complexity, synchronizing my Models and Views can be a pain. I've spent the past few months using existing View-Model binding libraries that others were kind enough to create and share with the world. Unfortunately in the majority of my application's Forms I wasn't able to leverage the existing libraries due to various limitations. Especially with nested models due to the inability to define binding scope. Type formatting or type conversion was also sometimes difficult.

This new Backbone.ModelBinder library tries build on lessons learned and:

  • Be simple as possible yet still be flexible and powerful
  • Allow for the ModelBinder to define element scope when binding - there might be elements you don't want to bind to for whatever reason
  • Allow the ModelBinder to only deal with html elements rather than Backbone Views
  • Allow you to bind to nested Views or composite Views - adding the ability to define scope and binding to elements makes this possible
  • Leverage the exact same jQuery syntax that the Backbone.View event blocks use
  • Easily handle type formatting and type conversion


Basic ModelBinder functionality

The Backbone.ModelBinder class contains all of the logic to facilitate View-Model binding.

You can use the ModelBinder to bind Model attributes to:

  • Read only html elements such as <span>, <div> etc.
  • element attributes such as enabled, displayed etc.
  • To editable Form elements such as input, textarea etc. This type of binding is bidirectional between the View and the Model.

The ModelBinder class exposes 3 public functions shown below:

// no arguments passed to the constructor
constructor();

// model is required, it is the Backbone Model your binding to
// rootEl is required, is the root html element containing the elements you want to bind to
// bindings is optional, it's discussed a bit later
bind(model, rootEl, bindings);

// unbinds the Model with the elements found under rootEl - defined when calling bind()
unbind();

Simple forms or read only elements that do not require any type conversion or formatting don't need to pass the 3rd optional `bindings` argument to the `bind()` function. If `bindings` is not defined, then bind() will locate all of the child elements under the rootEl that define a `name` attribute. Each of these elements will be bound to the Model's attributes - the value of the element's name attribute will be used as the Model's attribute name. In the example below, the Model's address attribute will be bound to the input text field with the name of 'address'.
<!-- The html -->
<input type="text" name="address"/>
SomeView = Backbone.View.extend({
    render: function(){        
        this.modelBinder.bind(this.model, this.el);
    }
});

You can also bind a Model's attribute to multiple elements on the same page. In the example below, the span's on the top and the input text elements are both bound to the same attribute because they share the same name attribute of "firstName" or "lastName". If you modified the firstName or lastName input element you would see the corresponding span automatically updated because the Model would have been updated.
<!-- The html -->
Welcome, <span name="firstName"></span> <span name="lastName"></span>

Edit your information:
<input type="text" name="firstName"/>
<input type="text" name="lastName"/>
// The javascript
SomeView = Backbone.View.extend({
    render: function(){        
        this.modelBinder.bind(this.model, this.el);
    }
});

***

The bindings parameter to the bind() function

If you use the ModelBinder without defining the bindings hash all of the rootEl's child elements with a 'name' attribute will be bound to your Model. You will probably need more flexibility than this default behavior for many of your Views, especially for ones that are editable.

For more complicated things like formatting or defining scope for composite or nested Views you'll need to define a bindings parameter - the optional 3rd parameter to the bind() function. The basic syntax of the bindings hash parameter is shown below.

// Example that binds Model.address to <input type="text" id="address"/>:
var bindings = {address: '#address'};
modelBinder.bind(this.model, this.el, bindings);   

// Example that binds Model.homeAddress to <input type="text" name="homeAddress"/> and
// Model.workAddress to <input type="text" name="workAddress "/>:
var bindings = {homeAddress: '[name=homeAddress]', workAddress : '[name=workAddress ]'};
modelBinder.bind(this.model, this.el, bindings);

The keys of the hash are the Model attribute names and the values in this example are strings that are jQuery selectors.


***

Formatting and converting values The binding values can also define a converter parameter. A simple of example of using a converter to format a number is shown below.

var phoneConverter = function(direction, value){
  // direction is either ModelToView or ViewToModel
  // Return either a formatted value for the View or an unformatted value for the Model
};

var bindings = {phoneNumber: {selector: '[name=phoneNumber]', converter: phoneConverter}}
modelBinder.bind(this.model, this.el, bindings );

A converter is simply a function that will be passed 4 parameters

  • Direction - either ModelToView or ViewToModel
  • Value - the value in the Model that should be formatted for the view, or the view formatted value
  • Attribute name
  • Model - this is more useful when your dealing with calculated attributes

Converters can be used for simple formatting operations like phone numbers but they can also be used for more advanced situations like when you want to convert between a Model and some description of the Model. You might want to display a list of Models in a <select> element - a converter will allow you to convert between a Model and a Model's id making this type of binding easy to do. The detailed docs describe how to do this in more detail.

Converters can also be used to display calculated attributes. This is shown at near the end of this article.



Binding to html element attributes

You can also define bindings to be bound to any html element's attribute like enabled, style, class or any other attribute you define. The example below shows how to use the elAttribute option. In this example, the address element will be enabled depending on what the Model.isAddressEnabled attribute is.

var bindings = {isAddressEnabled: {selector: '[name=address]',  elAttribute: 'enabled'}};
modelBinder.bind(this.model, this.el, bindings); 

You could also extend the example above to be a bit more complicated. Let's pretend the Model has an attribute called customerType and if customerType == 'residential' we want the address to enabled, otherwise we want it disabled. We can handle this type of binding by leveraging both `converter` and `elAttribute`. The example below shows how this would work. When the Model.customerType is updated, the address input element's enabled attribute would be updated.
var addressEnabledConverter = function(direction, value) { return value === 'residential'; };

var bindings = {customerType: {selector: '[name=address]',  elAttribute: 'enabled', converter: addressEnabledConverter}};
modelBinder.bind(this.model, this.el, bindings); 


Handling nested Models and Views

An example of a nested Backbone Model is shown below. The personModel has a nested homeAddressModel - the nested Model is also a Backbone.Model. You can bind to this type of structure fairly easily with the ModelBinder.

var personModel = new Backbone.Model({firstName: 'Herman', lastName: 'Munster'});
var homeAddressModel = new Backbone.Model({street: '1313 Mockingbird Lane', city: 'Mockingbird Heights'});

personModel.set({homeAddress: homeAddressModel});

The ModelBinder.bind() function only takes a single Model as an argument. Calling bind() will internally call unbind() to unbind the previous model and rootEl. You will need to define a separate ModelBinder instance for each Model that you want to bind to.

For the Model's above, you'll need to define a ModelBinder for the personModel and another ModelBinder for the homeAddressModel.

There are 2 basic ways to bind nested Models in a View:

  1. With a scoped rootEl that only contains html elements specific to the nested Model.
  2. With scoped bindings selectors in the bindings hash.

**Nested View option 1: A scoped `rootEl`**

If your nested view can be defined under a single parent element such as a <div> you can pass that parent element as the rootEl for your nested ModelBinder as shown in the example below. It refers to the personModel and homeAddressModels defined in a previous code snippet.

<!-- html --> 
<div id="personFields">
  <input type="text" name="firstName"/>
  <input type="text" name="lastName"/>
</div>
<div id="homeAddressFields">
  <input type="text" name="street"/>
  <input type="text" name="city"/>
</div>
personBinder.bind(this.personModel, this.$('#personFields'));
addressBinder.bind(this.personModel.get('homeAddress'), this.$('#homeAddressFields'));

In the example above, the nested homeAddressModel is bound to the correct fields because they are scoped by a single parent element. The personModel bindings also needed to be separately scoped as well. If the personModel fields were defined on a level that also included the homeAddressFields then the homeAddressFields would have appeared in the personModel. The next option shows how to avoid that situation.


**Nested View option 2: Scoping `bindings`**

If your parent and nested Model html elements cannot live under their own parent elements then you'll need to define the bindings with jQuery selectors that are properly scoped as shown in the example below.

<!-- Html -->
<input type="text" name="firstName"/>
<input type="text" name="lastName"/>
<input type="text" name="street"/>
<input type="text" name="city"/>
var personBindings = {firstName: '[name=firstName]', lastName: '[name=lastName]'};
personBinder.bind(this.personModel, this.el, personBindings);

var addressBindings = {street: '[name=street]', city: '[name=city]'};
addressBinder.bind(this.personModel.get('homeAddress'), this.el, addressBindings);

*** **Binding to computed values** Sometimes your Models will have computed attributes. If the Model's computed attribute is stored in the Models attribute collection then it's binding can be treated like any other attribute previously described. If your Model's computed attribute is calculated via a function we can use a `converter` for the binding. In the example below, we have a simple computed attribute named hoursLeft calculated by the function calculateHoursLeft().
SomeModel = Backbone.Model.extend({
    defaults: {currentHours: 3, totalHours: 8},
    
    calculateHoursLeft: function(){
        return this.get('totalHours') - this.get('currentHours');
    }
})

// Here is how how we create a binding for a calculated attribute
var bindings = {currentHours: {selector: '[name=hoursLeft]', converter: this.model.calculateHoursLeft}};
modelBinder.bind(this.model, this.el, bindings); 

In the example above, we are binding to the Model's attribute 'currentHours' because when currentHours changes the hoursLeft calculated value will change - the converter will be invoked at that time. The converter is simply the Model's calculateHoursLeft() function. The function just ignores the parameters passed to it and calculates the hours left.

If the currentHours attribute is also bound to another html element you could specify an array of element bindings in the binding definition like the example shown below.

var bindings = {currentHours: [ {selector: '[name=hoursLeft]', converter: this.model.calculateHoursLeft},
                                {selector: '[name=currentHours]'}]};
modelBinder.bind(this.model, this.el, bindings); 

Calculated values will almost never be writable so the function calculateHoursLeft can ignore the direction parameter and always assume the direction is ModelToView.

If converters need any other special logic they can be defined in another function outside of the Model because the converter function is passed the Model as a parameter.



**If this sounds interesting to you try it out [here](https://github.com/theironcook/Backbone.ModelBinder).**