Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Simple and small two-way data binding between DOM and data
/**
* @param {object} scope - Object that all bound data will be attached to.
*/
function twoWayBind(scope) {
// a list of all bindings used in the DOM
// @example
// { 'person.name': [<input type="text" data-bind="person.name"/>] }
var bindings = {};
// each bindings old value to be compared for changes
// @example
// { 'person.name': 'John' }
var oldValues = {};
/**
* Get the object of a binding.
*
* @param {string} path - Path to the bound object.
* @returns {object}
*/
function getBoundObject(path) {
path = path.split('.');
var binding = window;
for (var i = 0; i < path.length - 1; i++) {
if (typeof binding[ path[i] ] === 'undefined') return;
binding = binding[ path[i] ];
}
return binding;
}
/**
* Get the property of a binding.
*
* @param {string} path - Path to the bound object.
* @returns {string}
*/
function getBoundProperty(path) {
return path.substring(path.lastIndexOf('.') + 1);
}
/**
* Get the value of a binding.
*
* @param {string} path - Path to the bound object.
* @returns {*}
*/
function getBoundValue(path) {
var object = getBoundObject(path);
var property = getBoundProperty(path);
return (object ? object[property] : undefined);
}
/**
* Dirty check all bindings and update the DOM if any bindings have changed.
*/
function updateBindings() {
// if any binding changes, loop over all bindings again to see if the changed made
// any changes to other bindings. Similar to Angular.js dirty checking method.
var changed = true;
while (changed) {
changed = false;
// loop through all bindings and check their old value compared to their current value
for (var prop in bindings) {
if (!bindings.hasOwnProperty(prop)) continue;
var value = getBoundValue(prop)
if (typeof value === 'function') {
// a toString function must be called with it's associated object
// i.e. value = obj.toString; value = value(); doesn't work
value = value.call(getBoundObject(prop));
}
// value has changed, update all DOM
if (value !== oldValues[prop]) {
changed = true;
oldValues[prop] = value;
bindings[prop].forEach(function(node) {
if (node.nodeName === 'INPUT') {
node.value = (typeof value !== 'undefined' ? value : '');
}
else {
node.innerHTML = value;
}
});
}
}
}
}
/**
* Bind DOM nodes to their data. Can be used on DOM created after the page has loaded.
* @param {Node} node - Node to scan for bindings.
*/
function bindDom(node) {
var nodes = node.querySelectorAll('[data-bind]');
for (var i = 0, node; node = nodes[i]; i++) {
// set up initial values
var path = node.getAttribute('data-bind');
var value = getBoundValue(path);
if (typeof value === 'function') {
// a toString function must be called with it's associated object
// i.e. value = obj.toString; value = value(); doesn't work
value = value.call(getBoundObject(path));
}
if (node.nodeName === 'INPUT') {
node.value = (typeof value !== 'undefined' ? value : '');
}
else {
node.innerHTML = value;
}
// set old values for dirty checking
oldValues[path] = value;
// add the binds to the list
bindings[path] = bindings[path] || [];
if (bindings[path].indexOf(node) === -1) {
bindings[path].push(node);
}
}
}
// scan DOM once all scripts have run and bind DOM to data
// this allows scripts to inject DOM onto the page and still be bound
document.addEventListener('DOMContentLoaded', function() {
// bind DOM to data
bindDom(document);
// active DOM bindings on input change
document.addEventListener('change', function(e) {
var target = e.target;
// update the associated binding
if (target.hasAttribute('data-bind')) {
var path = target.getAttribute('data-bind');
var object = getBoundObject(path);
var property = getBoundProperty(path);
try {
object[property] = JSON.parse(target.value);
}
catch (e) {
object[property] = target.value;
}
updateBindings();
}
});
});
// attach functions for external use
scope.getBoundObject = getBoundObject;
scope.getBoundProperty = getBoundProperty;
scope.getBoundValue = getBoundValue;
scope.updateBindings = updateBindings;
scope.bindDom = bindDom;
}

Simple two-way data binding between DOM and data. No modern browser necessary, supported everywhere document.querySelector is (though IE9+ is helpful for using Object.defineProperties to broadcast changes).

Why

All examples of two-way binding I could find required using Object.observe (which requires a pollyfil), needed a JavaScript function that you pass all your bindings and DOM selectors, only updated the changed binding and didn't check for other bindings that relied on it, or a larger library. I wanted something small, that could work everywhere, propagated changes to all bindings, and allowed the DOM to define the bindings. So I wrote the code that would do it.

How it works

The code works by mapping DOM elements to JavaScript objects through the use of the data-bind attribute. The attribute's value is a path to the object property to bind to. Once a map from DOM element to object is created, all you have to do is call updateBindings whenever a value changes and the code will dirty check all bound properties for changes and update the DOM accordingly. Inputs will automatically call updateBindings on change events.

The only requirement is that all bindings must be attached to a single object, and that object must be passed into the two-way binding function call.

API

The code will attach 5 functions to your object that can be used in your code to broadcast changes or work with the bindings.

  • getBoundObject(path) - returns the object of a binding. Pass it the data-bind attribute.

    getBoundObject('person.name'); //=> person
  • getBoundProperty(path) - returns the property of a binding. Pass it the data-bind attribute.

    getBoundProperty('person.name'); //=> 'name'
  • getBoundValue(path) - returns the value of a binding. Pass it the data-bind attribute.

    person.name = 'John Doe';
    getBoundValue('person.name'); //=> 'John Doe'
  • updateBindings() - broadcast a binding change and update the DOM if any bindings have changed.

  • bindDom(node) - parse a DOM node for data-bind attributes and bind them to their objects. Use to bind any JavaScript created elements after the page has loaded.

Example

<!-- simple html binding -->
<span data-bind="person.name"></span>

<!-- input binding -->
<input type="text" data-bind="person.address"/>

<script>
var person = {
  name: 'John Doe',
  address: 'unknown location'
};

// two-way bind the person object to the DOM
twoWayBind(person);
</script>
<!-- use Object.defineProperties to broadcast changes in JavaScript to the DOM -->
<input type="text" data-bind="person.age" readonly/>

<script>
person = {_age: 20};

Object.defineProperties(person, {
  age: {
    get: function() {
      return this._age;
    },
    set: function(value) {
      this._age = value;
      
      person.updateBindings();
    }
  }
});

twoWayBind(person);

// later on
person.age = 40;
</script>

See it in action on CodePen

@Raiondesu

This comment has been minimized.

Copy link

commented Oct 4, 2018

Hey, @straker, great gist!
I wrote a standalone extendable js-library inspired by it.
Please, check it out! 😃

@StefansArya

This comment has been minimized.

Copy link

commented Nov 18, 2018

I also write a library for data-binding with DOM.
Let's try it out!

@pwFoo

This comment has been minimized.

Copy link

commented Nov 24, 2018

@StefansArya @Raiondesu @straker
rivetjs or fork tinybind have some additional features for templates (if, loops) which are really great!
But it looks like rivetjs / tinybind copy parts of DOM to do two way data bindings? Works your example different? With the "normal" DOM?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.