Data binding with Object.observe
Two-way data binding greatly simplifies writing views in web applications. Unfortunately until browsers support model-driven views the binding has to be managed by a library.
Backbone.js wraps the model adding change notification mechanism that can be used by views to update DOM elements. Angular uses plain JavaScript objects as a model and detects changes periodically during the special digest cycle.
The Object.observe
function makes writing data-binding code easier. This function planned for ES7 adds listeners to plain objects which are notified when the property value changes. The notifications are delivered asynchronously and in batches. Because of that and the fact that this function is implemented natively checking for changes using Object.observe
may be up to 40 times faster than the dirty checking used in Angular.js.
The code in this article uses the Object.observe
to implement a simple two-way data binding in about 100 lines of code.
Chrome (version 36) supports Object.observe
.
No other browser supports Object.observe
.
Demo
The following HTML template uses two special attributes:
data-bind
to connect the DOM element with an object property.data-repeat
to treat the DOM element as a template for all items in an array.
<ul>
<li>List of people:</li>
<li data-repeat="people">
<input data-bind="value name">
≡
<input data-bind="value name">
<ol>
<li data-repeat="hobbies">
<input data-bind="value label">
(exactly <span data-bind="count label"></span> letters)
</li>
<li>
<a href="#" data-bind="click addHobby">Add hobby…</a>
</li>
</ol>
</li>
</ul>
<a href="#" data-bind="click showStructure">Show structure</a>
The JavaScript code with a model that is connected to the template above:
var root = {
people: [ {
name: 'Ashley',
hobbies: [
{ label: 'programming' },
{ label: 'poetry' },
{ label: 'knitting' }
],
addHobby: addHobby
}, {
name: 'Noor',
hobbies: [],
addHobby: addHobby
} ],
showStructure: alertJSON
};
function addHobby() {
this.hobbies.push({
label: ''
});
}
function alertJSON() {
alert(JSON.stringify(root, null, 4));
}
withBinders(binders).bind(document.querySelector(".template"), root);
document.querySelector(".template").innerHTML = '<form class="form">' + html.get() + '</form>';
To run the example your browser has to support:
- Object.observe
Binders
Connecting DOM elements with objects is done using the designated object that handles the two-way data binding. The first part of the data-bind
indicates which binder to use:
value
— connects property to theINPUT
'svalue
.count
— connects property to thetextContent
counting the number of characters.click
— connects property (a function) to theclick
event of the node.
var binders = {
value: function(node, onchange) {
node.addEventListener('keyup', function() {
onchange(node.value);
});
return {
updateProperty: function(value) {
if (value !== node.value) {
node.value = value;
}
}
};
},
count: function(node) {
return {
updateProperty: function(value) {
node.textContent = String(value).length;
}
};
},
click: function(node, onchange, object) {
var previous;
return {
updateProperty: function(fn) {
var listener = function(e) {
fn.apply(object, arguments);
e.preventDefault();
};
if (previous) {
node.removeEventListener(previous);
previous = listener;
}
node.addEventListener('click', listener);
}
};
}
};
All binders expose an updateProperty
function that is used by the data binding code to update the DOM element when the property changes.
Connecting DOM elements to properties
The withBinders
function is divided into three parts:
bindObject
connects the binder to the object property and is invoked for eachdata-bind
attribute:<input data-bind="value name">
value
indicates which binder to use and thename
is a property of an object to bind to.bindCollection
is invoked for eachdata-repeat
attribute. It takes the node as a template to render for each element in an array. It also sets up the observer for new, updated or deleted items in the array.bindModel
that looks for alldata-bind
anddata-repeat
attributes in a given node and its children and invokes the two previous functions. The elements are filtered so that only attributes not nested inside thedata-repeat
template are processed.
function withBinders(binders) {
function bindObject(node, binderName, object, propertyName) {
var updateValue = function(newValue) {
object[propertyName] = newValue;
};
var binder = binders[binderName](node, updateValue, object);
binder.updateProperty(object[propertyName]);
var observer = function(changes) {
var changed = changes.some(function(change) {
return change.name === propertyName;
});
if (changed) {
binder.updateProperty(object[propertyName]);
}
};
Object.observe(object, observer);
return {
unobserve: function() {
Object.unobserve(object, observer);
}
};
}
function bindCollection(node, array) {
function capture(original) {
var before = original.previousSibling;
var parent = original.parentNode;
var node = original.cloneNode(true);
original.parentNode.removeChild(original);
return {
insert: function() {
var newNode = node.cloneNode(true);
parent.insertBefore(newNode, before);
return newNode;
}
};
}
delete node.dataset.repeat;
var parent = node.parentNode;
var captured = capture(node);
var bindItem = function(element) {
return bindModel(captured.insert(), element);
};
var bindings = array.map(bindItem);
var observer = function(changes) {
changes.forEach(function(change) {
var index = parseInt(change.name, 10), child;
if (isNaN(index)) return;
if (change.type === 'add') {
bindings.push(bindItem(array[index]));
} else if (change.type === 'update') {
bindings[index].unobserve();
bindModel(parent.children[index], array[index]);
} else if (change.type === 'delete') {
bindings.pop().unobserve();
child = parent.children[index];
child.parentNode.removeChild(child);
}
});
};
Object.observe(array, observer);
return {
unobserve: function() {
Object.unobserve(array, observer);
}
};
}
function bindModel(container, object) {
function isDirectNested(node) {
node = node.parentElement;
while (node) {
if (node.dataset.repeat) {
return false;
}
node = node.parentElement;
}
return true;
}
function onlyDirectNested(selector) {
var collection = container.querySelectorAll(selector);
return Array.prototype.filter.call(collection, isDirectNested);
}
var bindings = onlyDirectNested('[data-bind]').map(function(node) {
var parts = node.dataset.bind.split(' ');
return bindObject(node, parts[0], object, parts[1]);
}).concat(onlyDirectNested('[data-repeat]').map(function(node) {
return bindCollection(node, object[node.dataset.repeat]);
}));
return {
unobserve: function() {
bindings.forEach(function(binding) {
binding.unobserve();
});
}
};
}
return {
bind: bindModel
};
}
The main problem with this solution is that it relies on the Object.observe
function that is not supported in any browser by default. There are also several drawbacks of the current implementation of withBinders
:
- It cannot bind to primitives directly — if
hobbies
was an array of strings (and not objects) it would not be possible to bind to them. - There is no way to bind to nested properties — if
name
property was an object (for example:{ firstName: 'FN', lastName: 'LN'}
) then there is no way to bind to thename.firstName
. - When a binder triggers
onchange
then it is also updated with the same value. That can lead to update cycles or losing context. Removeif (value !== node.value)
condition in thevalue
binder and observe how cursor position is overwritten when editing text inside the input field (insert a character at the beginning).
Comments
Revisions
- Initial version.
- New
Object.observe
API usesadd
instead ofnew
. - Updated information about Chrome's implementation.