Data binding with Object.observe

comment

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:

  1. data-bind to connect the DOM element with an object property.
  2. 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);

To run the example your browser has to support:

  • Object.observe
Click Render to display view.

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 the INPUT's value.
  • count — connects property to the textContent counting the number of characters.
  • click — connects property (a function) to the click 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 each data-bind attribute:
    <input data-bind="value name">

    value indicates which binder to use and the name is a property of an object to bind to.

  • bindCollection is invoked for each data-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 all data-bind and data-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 the data-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:

  1. 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.
  2. 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 the name.firstName.
  3. When a binder triggers onchange then it is also updated with the same value. That can lead to update cycles or losing context. Remove if (value !== node.value) condition in the value binder and observe how cursor position is overwritten when editing text inside the input field (insert a character at the beginning).

Comments

cancel

Revisions

  1. Initial version.
  2. New Object.observe API uses add instead of new.
  3. Updated information about Chrome's implementation.