Unobtrusive databinding in Knockout JS
Introduction
I've been using Knockout JS for quite a few years and it's a great that library that 'helps you to create rich, responsive display and editor user interfaces with a clean underlying data model.'[1] However, one thing I really dislike is the use of the data-bind attribute (see Figure 1), for three reasons:
- It mixes markup and code - databinding shouldn't be expressed in markup, in my opinion (separation of concerns);
- It bloats the markup; and
- It can be a laborious task, adding all of the bindings.
<p>First name: <input data-bind="value: firstName" /></p>Figure 1
<p>Last name: <input data-bind="value: lastName" /></p>
<h2>Hello, <span data-bind="text: fullName"></span>!</h2>
Wouldn't it be easier and cleaner if Knockout JS was able to bind automatically? For example:
<p>First name: <input name="firstName" /></p>Figure 2
<p>Last name: <input name="lastName" /></p>
<h2>Hello, <span id="fullName"></span>!</h2>
The knockout.unobtrusiveBindingProvider
"knockout.unobtrusiveBindingProvider is an unobtrusive, convention-based binding provider for Knockout JS that enables a clean separation of HTML and Knockout databindings."[2]
How does it work? As Knockout traverses the DOM, the unobtrusiveBindingProvider analyses each HTML element and:
- Using the
id,name(for<input>or<select>tags) or aclassname, attempts to map to a member of the current$dataobject; and - Based on the HTML element type and the type of the member, determines what binding should be used (see Table 1).
Table 1
HTML element Member type Binding * Number, String text * Boolean visible * Object, Observable with * Array, ObservableArray foreach * Function click input Number, String, Observable value input[type="checkbox|radio"] Boolean checked select Object, Number, String, Observable value select Array, ObservableArray selectedOptions
- If the appropriate member isn't found, in the current context, the unobtrusiveBindingProvider will attempt to find one by bubbling up through the parent contexts.
- Where an element has multiple classes and a member hasn't already been mapped, the unobtrusiveBindingProvider will attempt to map each class until a member has been found.
- If the target (
id,nameorclass) is hyphonated and a member hasn't already been mapped, then the unobtrusiveBindingProvider will regard this as a path to a member in the object graph and will attempt to navigate through the object graph until it reaches the destination member. However, if any part of the hyphonated target doesn't match a path in the object graph, the mapping will be terminated. If any part of the path is an observable then the parentheses should be omitted.
But what happens if we don't want to use the binding that is mapped in Table 1?
The binding extender
In the scenario where you need to use a different binding the unobtrusiveBindingProvider includes a binding extender (see Figure 3), which will override the mapping.
this.firstName = ko.observable("Bob").extend({ binding: "textInput" });Figure 3The bindings extender
Quite often we need to declare additional bindings. The unobtrusiveBindingProvider enables this through the use of the bindings extender. For example:
<select name="hairColour"></select>Figure 4
this.hairColours = ko.observableArray(["Black","Blonde","Brunette","Grey","Red","White"]);
this.hairColour = ko.observable("Blonde").extend({ bindings: "options:hairColours" });
Binding Arrays and Functions
If the member of the model is an array or function then the ko.utils.extend method can be used to add bindings. For example:
<button id="sayHello">Say Hello</button>Figure 5
this.hairColours = ko.utils.extend([...], { bindings: "attr:{title:'colours'}" });
this.sayHello = ko.utils.extend(function(){...}, { bindings: "enable:firstName() && lastName()" });
The binding of a function can be overridden with an event name. For example:
this.sayHello = ko.utils.extend(function(){...}, { binding: "mouseover" });Figure 6The ignore extender
Where the id, name or a class matches a member but the member shouldn't be bound to the view, the unobtrusiveBindingProvider includes an ignore extender. For example:
this.fullName = ko.pureComputed(...).extend({ ignore: true });Figure 7The ko.bindings property
Views will often declare bindings that don't map to a member of the model. In this scenario, the unobtrusiveBindingProvider will use a property, on the ko object, called bindings. This should be an object with property names that map to the id or a class of a HTML element. NB: The CSS convention, when a selector comprises multiple words, is to hyphonate the words. Bindings names can be hyphonated, as long as they are surrounded with quotation marks. For example:
ko.bindings = { "say-hello": "click:sayHello" };Figure 8Overriding model members
Bindings declared in the ko.bindings object can override bindings declared on a member. To do this the property must be assigned an object, which includes a bindings property and an override property (set to true). For example:
ko.binding = { "say-hello": { bindings: "click:sayGoodbye", override: true } };Figure 9The ko.debug property
One of the reasons for the unobtrusiveBindingProvider is to eliminate the data-bind attribute. However, it can be useful to see what the bindings are, during debugging. The ko.debug property allows us to define the conditions under which the data-bind attribute should be added. The ko.debug property is automatically set to true if the hostname is "localhost" or the protocol is "file:" but this can be overridden. For example:
ko.debug = location.hostname === "stevenbey.com";Figure 10
Conclusion
The unobtrusiveBindingProvider enables you to maintain clean and simple HTML, whilst observing separation of concerns.