270
votes

Please treat this question as strictly educational. I'm still interested in hearing new answers and ideas to implement this

tl;dr

How would I implement bi-directional data-binding with JavaScript?

Data Binding to the DOM

By data binding to the DOM I mean for example, having a JavaScript object a with a property b. Then having an <input> DOM element (for example), when the DOM element changes, a changes and vice versa (that is, I mean bidirectional data binding).

Here is a diagram from AngularJS on what this looks like:

two way data binding

So basically I have JavaScript similar to:

var a = {b:3};

Then an input (or other form) element like:

<input type='text' value=''>

I'd like the input's value to be a.b's value (for example), and when the input text changes, I'd like a.b to change too. When a.b changes in JavaScript, the input changes.

The Question

What are some basic techniques to accomplish this in plain JavaScript?

In specific, I'd like a good answer to refer to:

  • How would binding work for objects?
  • How listening to change in the form might work?
  • Is it possible in a simple way to only have the HTML modified on the template level? I'd like to not keep track of the binding in the HTML document itself but only in JavaScript (with DOM events, and JavaScript keeping reference to the DOM elements used).

What have I tried?

I'm a big fan of Mustache so I tried using it for templating. However, I ran into issues when trying to perform the data binding itself since Mustache processes HTML as a string so after I get its result I have no reference to where the objects in my viewmodel are. The only workaround I could think for this was modifying the HTML string (or created DOM tree) itself with attributes. I don't mind using a different templating engine.

Basically, I got a strong feeling that I was complicating the issue at hand and there is a simple solution.

Note: Please do not provide answers that use external libraries, especially ones that are thousands of lines of code. I've used (and like!) AngularJS and KnockoutJS. I really don't want answers in the form 'use framework x'. Optimally, I'd like a future reader who doesn't know how to use many frameworks to grasp how to implement bi-directional data-binding herself. I do not expect a complete answer, but one that gets the idea across.

15
I based CrazyGlue on Benjamin Gruenbaum's design. It also supports SELECT, checkbox and radio tags. jQuery is a dependency.JohnSz
This question is totally awesome. If it ever gets closed for being off-topic or some other silly nonsense, I'm going to be seriously ticked off.OCDev
@JohnSz thanks for mentioning your CrazyGlue project. I've been searching for a simple 2 way data binder for a long time. It looks like you aren't using Object.observe so your browser support should be great. And you aren't using mustache templating so its perfect.Gavin
@Benjamin What did you end up doing?johnny
from year 2021 -> thank you for this question and discussions !@#Eva Cohen

15 Answers

117
votes
  • How would binding work for objects?
  • How listening to change in the form might work?

An abstraction that updates both objects

I suppose there are other techniques, but ultimately I'd have an object that holds reference to a related DOM element, and provides an interface that coordinates updates to its own data and its related element.

The .addEventListener() provides a very nice interface for this. You can give it an object that implements the eventListener interface, and it'll invoke its handlers with that object as the this value.

This gives you automatic access to both the element and its related data.

Defining your object

Prototypal inheritance is a nice way to implement this, though not required of course. First you'd create a constructor that receives your element and some initial data.

function MyCtor(element, data) {
    this.data = data;
    this.element = element;
    element.value = data;
    element.addEventListener("change", this, false);
}

So here the constructor stores the element and data on properties of the new object. It also binds a change event to the given element. The interesting thing is that it passes the new object instead of a function as the second argument. But this alone won't work.

Implementing the eventListener interface

To make this work, your object needs to implement the eventListener interface. All that's needed to accomplish this is to give the object a handleEvent() method.

That's where the inheritance comes in.

MyCtor.prototype.handleEvent = function(event) {
    switch (event.type) {
        case "change": this.change(this.element.value);
    }
};

MyCtor.prototype.change = function(value) {
    this.data = value;
    this.element.value = value;
};

There are many different ways in which this could be structured, but for your example of coordinating updates, I decided to make the change() method only accept a value, and have the handleEvent pass that value instead of the event object. This way the change() can be invoked without an event as well.

So now, when the change event happens, it'll update both the element and the .data property. And the same will happen when you call .change() in your JavaScript program.

Using the code

Now you'd just create the new object, and let it perform updates. Updates in JS code will appear on the input, and change events on the input will be visible to the JS code.

var obj = new MyCtor(document.getElementById("foo"), "20");

// simulate some JS based changes.
var i = 0;
setInterval(function() {
    obj.change(parseInt(obj.element.value) + ++i);
}, 3000);

DEMO: http://jsfiddle.net/RkTMD/

37
votes

So, I decided to throw my own solution in the pot. Here is a working fiddle. Note this only runs on very modern browsers.

What it uses

This implementation is very modern - it requires a (very) modern browser and users two new technologies:

  • MutationObservers to detect changes in the dom (event listeners are used as well)
  • Object.observe to detect changes in the object and notifying the dom. Danger, since this answer has been written O.o has been discussed and decided against by the ECMAScript TC, consider a polyfill.

How it works

  • On the element, put a domAttribute:objAttribute mapping - for example bind='textContent:name'
  • Read that in the dataBind function. Observe changes to both the element and the object.
  • When a change occurs - update the relevant element.

The solution

Here is the dataBind function, note it's just 20 lines of code and could be shorter:

function dataBind(domElement, obj) {    
    var bind = domElement.getAttribute("bind").split(":");
    var domAttr = bind[0].trim(); // the attribute on the DOM element
    var itemAttr = bind[1].trim(); // the attribute the object

    // when the object changes - update the DOM
    Object.observe(obj, function (change) {
        domElement[domAttr] = obj[itemAttr]; 
    });
    // when the dom changes - update the object
    new MutationObserver(updateObj).observe(domElement, { 
        attributes: true,
        childList: true,
        characterData: true
    });
    domElement.addEventListener("keyup", updateObj);
    domElement.addEventListener("click",updateObj);
    function updateObj(){
        obj[itemAttr] = domElement[domAttr];   
    }
    // start the cycle by taking the attribute from the object and updating it.
    domElement[domAttr] = obj[itemAttr]; 
}

Here is some usage:

HTML:

<div id='projection' bind='textContent:name'></div>
<input type='text' id='textView' bind='value:name' />

JavaScript:

var obj = {
    name: "Benjamin"
};
var el = document.getElementById("textView");
dataBind(el, obj);
var field = document.getElementById("projection");
dataBind(field,obj);

Here is a working fiddle. Note that this solution is pretty generic. Object.observe and mutation observer shimming is available.

30
votes

I'd like to add to my preposter. I suggest a slightly different approach that will allow you to simply assign a new value to your object without using a method. It must be noted though that this is not supported by especially older browsers and IE9 still requires use of a different interface.

Most notably is that my approach does not make use of events.

Getters and Setters

My proposal makes use of the relatively young feature of getters and setters, particularly setters only. Generally speaking, mutators allow us to "customize" the behavior of how certain properties are assigned a value and retrieved.

One implementation I'll be using here is the Object.defineProperty method. It works in FireFox, GoogleChrome and - I think - IE9. Haven't tested other browsers, but since this is theory only...

Anyways, it accepts three parameters. The first parameter being the object that you wish to define a new property for, the second a string resembling the the name of the new property and the last a "descriptor object" providing information on the behavior of the new property.

Two particularly interesting descriptors are get and set. An example would look something like the following. Note that using these two prohibits the use of the other 4 descriptors.

function MyCtor( bindTo ) {
    // I'll omit parameter validation here.

    Object.defineProperty(this, 'value', {
        enumerable: true,
        get : function ( ) {
            return bindTo.value;
        },
        set : function ( val ) {
            bindTo.value = val;
        }
    });
}

Now making use of this becomes slightly different:

var obj = new MyCtor(document.getElementById('foo')),
    i = 0;
setInterval(function() {
    obj.value += ++i;
}, 3000);

I want to emphasize that this only works for modern browsers.

Working fiddle: http://jsfiddle.net/Derija93/RkTMD/1/

7
votes

I think my answer will be more technical, but not different as the others present the same thing using different techniques.
So, first things first, the solution to this problem is the use of a design pattern known as "observer", it let's you decouple your data from your presentation, making the change in one thing be broadcasted to their listeners, but in this case it's made two-way.

For the DOM to JS way

To bind the data from the DOM to the js object you may add markup in the form of data attributes (or classes if you need compatibility), like this:

<input type="text" data-object="a" data-property="b" id="b" class="bind" value=""/>
<input type="text" data-object="a" data-property="c" id="c" class="bind" value=""/>
<input type="text" data-object="d" data-property="e" id="e" class="bind" value=""/>

This way it can be accessed via js using querySelectorAll (or the old friend getElementsByClassName for compatibility).

Now you can bind the event listening to the changes in to ways: one listener per object or one big listener to the container/document. Binding to the document/container will trigger the event for every change made in it or it's child, it willhave a smaller memory footprint but will spawn event calls.
The code will look something like this:

//Bind to each element
var elements = document.querySelectorAll('input[data-property]');

function toJS(){
    //Assuming `a` is in scope of the document
    var obj = document[this.data.object];
    obj[this.data.property] = this.value;
}

elements.forEach(function(el){
    el.addEventListener('change', toJS, false);
}

//Bind to document
function toJS2(){
    if (this.data && this.data.object) {
        //Again, assuming `a` is in document's scope
        var obj = document[this.data.object];
        obj[this.data.property] = this.value;
    }
}

document.addEventListener('change', toJS2, false);

For the JS do DOM way

You will need two things: one meta-object that will hold the references of witch DOM element is binded to each js object/attribute and a way to listen to changes in objects. It is basically the same way: you have to have a way to listen to changes in the object and then bind it to the DOM node, as your object "can't have" metadata you will need another object that holds metadata in a way that the property name maps to the metadata object's properties. The code will be something like this:

var a = {
        b: 'foo',
        c: 'bar'
    },
    d = {
        e: 'baz'
    },
    metadata = {
        b: 'b',
        c: 'c',
        e: 'e'
    };
function toDOM(changes){
    //changes is an array of objects changed and what happened
    //for now i'd recommend a polyfill as this syntax is still a proposal
    changes.forEach(function(change){
        var element = document.getElementById(metadata[change.name]);
        element.value = change.object[change.name];
    });
}
//Side note: you can also use currying to fix the second argument of the function (the toDOM method)
Object.observe(a, toDOM);
Object.observe(d, toDOM);

I hope that i was of help.

7
votes

Yesterday, I started to write my own way to bind data.

It's very funny to play with it.

I think it's beautiful and very useful. At least on my tests using firefox and chrome, Edge must works too. Not sure about others, but if they support Proxy, I think it will work.

https://jsfiddle.net/2ozoovne/1/

<H1>Bind Context 1</H1>
<input id='a' data-bind='data.test' placeholder='Button Text' />
<input id='b' data-bind='data.test' placeholder='Button Text' />
<input type=button id='c' data-bind='data.test' />
<H1>Bind Context 2</H1>
<input id='d' data-bind='data.otherTest' placeholder='input bind' />
<input id='e' data-bind='data.otherTest' placeholder='input bind' />
<input id='f' data-bind='data.test' placeholder='button 2 text - same var name, other context' />
<input type=button id='g' data-bind='data.test' value='click here!' />
<H1>No bind data</H1>
<input id='h' placeholder='not bound' />
<input id='i' placeholder='not bound'/>
<input type=button id='j' />

Here is the code:

(function(){
    if ( ! ( 'SmartBind' in window ) ) { // never run more than once
        // This hack sets a "proxy" property for HTMLInputElement.value set property
        var nativeHTMLInputElementValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
        var newDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
        newDescriptor.set=function( value ){
            if ( 'settingDomBind' in this )
                return;
            var hasDataBind=this.hasAttribute('data-bind');
            if ( hasDataBind ) {
                this.settingDomBind=true;
                var dataBind=this.getAttribute('data-bind');
                if ( ! this.hasAttribute('data-bind-context-id') ) {
                    console.error("Impossible to recover data-bind-context-id attribute", this, dataBind );
                } else {
                    var bindContextId=this.getAttribute('data-bind-context-id');
                    if ( bindContextId in SmartBind.contexts ) {
                        var bindContext=SmartBind.contexts[bindContextId];
                        var dataTarget=SmartBind.getDataTarget(bindContext, dataBind);
                        SmartBind.setDataValue( dataTarget, value);
                    } else {
                        console.error( "Invalid data-bind-context-id attribute", this, dataBind, bindContextId );
                    }
                }
                delete this.settingDomBind;
            }
            nativeHTMLInputElementValue.set.bind(this)( value );
        }
        Object.defineProperty(HTMLInputElement.prototype, 'value', newDescriptor);

    var uid= function(){
           return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
               var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
               return v.toString(16);
          });
   }

        // SmartBind Functions
        window.SmartBind={};
        SmartBind.BindContext=function(){
            var _data={};
            var ctx = {
                "id" : uid()    /* Data Bind Context Id */
                , "_data": _data        /* Real data object */
                , "mapDom": {}          /* DOM Mapped objects */
                , "mapDataTarget": {}       /* Data Mapped objects */
            }
            SmartBind.contexts[ctx.id]=ctx;
            ctx.data=new Proxy( _data, SmartBind.getProxyHandler(ctx, "data"))  /* Proxy object to _data */
            return ctx;
        }

        SmartBind.getDataTarget=function(bindContext, bindPath){
            var bindedObject=
                { bindContext: bindContext
                , bindPath: bindPath 
                };
            var dataObj=bindContext;
            var dataObjLevels=bindPath.split('.');
            for( var i=0; i<dataObjLevels.length; i++ ) {
                if ( i == dataObjLevels.length-1 ) { // last level, set value
                    bindedObject={ target: dataObj
                    , item: dataObjLevels[i]
                    }
                } else {    // digg in
                    if ( ! ( dataObjLevels[i] in dataObj ) ) {
                        console.warn("Impossible to get data target object to map bind.", bindPath, bindContext);
                        break;
                    }
                    dataObj=dataObj[dataObjLevels[i]];
                }
            }
            return bindedObject ;
        }

        SmartBind.contexts={};
        SmartBind.add=function(bindContext, domObj){
            if ( typeof domObj == "undefined" ){
                console.error("No DOM Object argument given ", bindContext);
                return;
            }
            if ( ! domObj.hasAttribute('data-bind') ) {
                console.warn("Object has no data-bind attribute", domObj);
                return;
            }
            domObj.setAttribute("data-bind-context-id", bindContext.id);
            var bindPath=domObj.getAttribute('data-bind');
            if ( bindPath in bindContext.mapDom ) {
                bindContext.mapDom[bindPath][bindContext.mapDom[bindPath].length]=domObj;
            } else {
                bindContext.mapDom[bindPath]=[domObj];
            }
            var bindTarget=SmartBind.getDataTarget(bindContext, bindPath);
            bindContext.mapDataTarget[bindPath]=bindTarget;
            domObj.addEventListener('input', function(){ SmartBind.setDataValue(bindTarget,this.value); } );
            domObj.addEventListener('change', function(){ SmartBind.setDataValue(bindTarget, this.value); } );
        }

        SmartBind.setDataValue=function(bindTarget,value){
            if ( ! ( 'target' in bindTarget ) ) {
                var lBindTarget=SmartBind.getDataTarget(bindTarget.bindContext, bindTarget.bindPath);
                if ( 'target' in lBindTarget ) {
                    bindTarget.target=lBindTarget.target;
                    bindTarget.item=lBindTarget.item;
                } else {
                    console.warn("Still can't recover the object to bind", bindTarget.bindPath );
                }
            }
            if ( ( 'target' in bindTarget ) ) {
                bindTarget.target[bindTarget.item]=value;
            }
        }
        SmartBind.getDataValue=function(bindTarget){
            if ( ! ( 'target' in bindTarget ) ) {
                var lBindTarget=SmartBind.getDataTarget(bindTarget.bindContext, bindTarget.bindPath);
                if ( 'target' in lBindTarget ) {
                    bindTarget.target=lBindTarget.target;
                    bindTarget.item=lBindTarget.item;
                } else {
                    console.warn("Still can't recover the object to bind", bindTarget.bindPath );
                }
            }
            if ( ( 'target' in bindTarget ) ) {
                return bindTarget.target[bindTarget.item];
            }
        }
        SmartBind.getProxyHandler=function(bindContext, bindPath){
            return  {
                get: function(target, name){
                    if ( name == '__isProxy' )
                        return true;
                    // just get the value
                    // console.debug("proxy get", bindPath, name, target[name]);
                    return target[name];
                }
                ,
                set: function(target, name, value){
                    target[name]=value;
                    bindContext.mapDataTarget[bindPath+"."+name]=value;
                    SmartBind.processBindToDom(bindContext, bindPath+"."+name);
                    // console.debug("proxy set", bindPath, name, target[name], value );
                    // and set all related objects with this target.name
                    if ( value instanceof Object) {
                        if ( !( name in target) || ! ( target[name].__isProxy ) ){
                            target[name]=new Proxy(value, SmartBind.getProxyHandler(bindContext, bindPath+'.'+name));
                        }
                        // run all tree to set proxies when necessary
                        var objKeys=Object.keys(value);
                        // console.debug("...objkeys",objKeys);
                        for ( var i=0; i<objKeys.length; i++ ) {
                            bindContext.mapDataTarget[bindPath+"."+name+"."+objKeys[i]]=target[name][objKeys[i]];
                            if ( typeof value[objKeys[i]] == 'undefined' || value[objKeys[i]] == null || ! ( value[objKeys[i]] instanceof Object ) || value[objKeys[i]].__isProxy )
                                continue;
                            target[name][objKeys[i]]=new Proxy( value[objKeys[i]], SmartBind.getProxyHandler(bindContext, bindPath+'.'+name+"."+objKeys[i]));
                        }
                        // TODO it can be faster than run all items
                        var bindKeys=Object.keys(bindContext.mapDom);
                        for ( var i=0; i<bindKeys.length; i++ ) {
                            // console.log("test...", bindKeys[i], " for ", bindPath+"."+name);
                            if ( bindKeys[i].startsWith(bindPath+"."+name) ) {
                                // console.log("its ok, lets update dom...", bindKeys[i]);
                                SmartBind.processBindToDom( bindContext, bindKeys[i] );
                            }
                        }
                    }
                    return true;
                }
            };
        }
        SmartBind.processBindToDom=function(bindContext, bindPath) {
            var domList=bindContext.mapDom[bindPath];
            if ( typeof domList != 'undefined' ) {
                try {
                    for ( var i=0; i < domList.length ; i++){
                        var dataTarget=SmartBind.getDataTarget(bindContext, bindPath);
                        if ( 'target' in dataTarget )
                            domList[i].value=dataTarget.target[dataTarget.item];
                        else
                            console.warn("Could not get data target", bindContext, bindPath);
                    }
                } catch (e){
                    console.warn("bind fail", bindPath, bindContext, e);
                }
            }
        }
    }
})();

Then, to set, just:

var bindContext=SmartBind.BindContext();
SmartBind.add(bindContext, document.getElementById('a'));
SmartBind.add(bindContext, document.getElementById('b'));
SmartBind.add(bindContext, document.getElementById('c'));

var bindContext2=SmartBind.BindContext();
SmartBind.add(bindContext2, document.getElementById('d'));
SmartBind.add(bindContext2, document.getElementById('e'));
SmartBind.add(bindContext2, document.getElementById('f'));
SmartBind.add(bindContext2, document.getElementById('g'));

setTimeout( function() {
    document.getElementById('b').value='Via Script works too!'
}, 2000);

document.getElementById('g').addEventListener('click',function(){
bindContext2.data.test='Set by js value'
})

For now, I've just added the HTMLInputElement value bind.

Let me know if you know how to improve it.

6
votes

There is a very simple barebones implementation of 2-way data-binding in this link "Easy Two-Way Data Binding in JavaScript"

The previous link along with ideas from knockoutjs, backbone.js and agility.js, led to this light-weight and fast MVVM framework, ModelView.js based on jQuery which plays nicely with jQuery and of which i am the humble (or maybe not so humble) author.

Reproducing sample code below (from blog post link):

Sample code for DataBinder

function DataBinder( object_id ) {
  // Use a jQuery object as simple PubSub
  var pubSub = jQuery({});

  // We expect a `data` element specifying the binding
  // in the form: data-bind-<object_id>="<property_name>"
  var data_attr = "bind-" + object_id,
      message = object_id + ":change";

  // Listen to change events on elements with the data-binding attribute and proxy
  // them to the PubSub, so that the change is "broadcasted" to all connected objects
  jQuery( document ).on( "change", "[data-" + data_attr + "]", function( evt ) {
    var $input = jQuery( this );

    pubSub.trigger( message, [ $input.data( data_attr ), $input.val() ] );
  });

  // PubSub propagates changes to all bound elements, setting value of
  // input tags or HTML content of other tags
  pubSub.on( message, function( evt, prop_name, new_val ) {
    jQuery( "[data-" + data_attr + "=" + prop_name + "]" ).each( function() {
      var $bound = jQuery( this );

      if ( $bound.is("input, textarea, select") ) {
        $bound.val( new_val );
      } else {
        $bound.html( new_val );
      }
    });
  });

  return pubSub;
}

For what concerns the JavaScript object, a minimal implementation of a User model for the sake of this experiment could be the following:

function User( uid ) {
  var binder = new DataBinder( uid ),

      user = {
        attributes: {},

        // The attribute setter publish changes using the DataBinder PubSub
        set: function( attr_name, val ) {
          this.attributes[ attr_name ] = val;
          binder.trigger( uid + ":change", [ attr_name, val, this ] );
        },

        get: function( attr_name ) {
          return this.attributes[ attr_name ];
        },

        _binder: binder
      };

  // Subscribe to the PubSub
  binder.on( uid + ":change", function( evt, attr_name, new_val, initiator ) {
    if ( initiator !== user ) {
      user.set( attr_name, new_val );
    }
  });

  return user;
}

Now, whenever we want to bind a model’s property to a piece of UI we just have to set an appropriate data attribute on the corresponding HTML element:

// javascript
var user = new User( 123 );
user.set( "name", "Wolfgang" );

<!-- html -->
<input type="number" data-bind-123="name" />
4
votes

A simple way of binding a variable to an input (two-way binding) is to just directly access the input element in the getter and setter:

var variable = function(element){                    
                   return {
                       get : function () { return element.value;},
                       set : function (value) { element.value = value;} 
                   }
               };

In HTML:

<input id="an-input" />
<input id="another-input" />

And to use:

var myVar = new variable(document.getElementById("an-input"));
myVar.set(10);

// and another example:
var myVar2 = new variable(document.getElementById("another-input"));
myVar.set(myVar2.get());


var variable = function(element){

                return function () {
                    if(arguments.length > 0)                        
                        element.value = arguments[0];                                           

                    else return element.value;                                                  
                }

        }

To use:

var v1 = new variable(document.getElementById("an-input"));
v1(10); // sets value to 20.
console.log(v1()); // reads value.
4
votes

Late to the party, especially since I've written 2 libs related months/years ago, I'll mention them later, but still looks relevant to me. To make it really short spoiler, the technologies of my choice are:

  • Proxy for observation of model
  • MutationObserver for the tracking changes of DOM (for binding reasons, not value changes)
  • value changes (view to model flow) are handled via regular addEventListener handlers

IMHO, in addition to the OP, it is important that data binding implementation will:

  • handle different app lifecycle cases (HTML first, then JS, JS first then HTML, dynamic attributes change etc)
  • allow deep binding of model, so that one may bind user.address.block
  • arrays as a model should be supported correctly (shift, splice and alike)
  • handle ShadowDOM
  • attempt to be as easy for technology replacement as possible, thus any templating sub-languages are a non-future-changes-friendly approach since it's too heavily coupled with framework

Taking all those into consideration, in my opinion makes it impossible to just throw few dozens of JS lines. I've tried to do it as a pattern rather than lib - didn't work for me.

Next, having Object.observe is removed, and yet given that observation of model is crucial part - this whole part MUST be concern-separated to another lib. Now to the point of principals of how I took this problem - exactly as OP asked:

Model (JS part)

My take for model observation is Proxy, it is the only sane way to make it work, IMHO. Fully featured observer deserves it's own library, so I've developed object-observer library for that sole purpose.

The model/s should be registered via some dedicated API, that's the point where POJOs turn into Observables, can't see any shortcut here. The DOM elements which are considered to be a bound views (see below), are updated with the values of the model/s at first and then upon each data change.

Views (HTML part)

IMHO, the cleanest way to express the binding, is via attributes. Many did this before and many will do after, so no news here, this is just a right way to do that. In my case I've gone with the following syntax: <span data-tie="modelKey:path.to.data => targerProperty"></span>, but this is less important. What is important to me, no complex scripting syntax in the HTML - this is wrong, again, IMHO.

All the elements designated to be a bound views shall be collected at first. It looks inevitable to me from a performance side to manage some internal mapping between the models and the views, seems a right case where memory + some management should be sacrificed to save runtime lookups and updates.

The views are updated at first from the model, if available and upon later model changes, as we said. More yet, the whole DOM should be observed by means of MutationObserver in order to react (bind/unbind) on the dynamically added/remove/changed elements. Furthermore, all this, should be replicated into the ShadowDOM (open one, of course) in order to not leave unbound black holes.

The list of specifics may go further indeed, but those are in my opinion the main principals that would made data binding implemented with a good balance of feature completeness from one and sane simplicity from the other side.

And thus, in addition to the object-observer mentioned above, I've written indeed also data-tier library, that implements data binding along the above mentioned concepts.

4
votes

Things have changed a lot in the last 7 years, we have native web components in most browsers now. IMO the core of the problem is sharing state between elements, once you have that its trivial to update the ui when state changes and vice versa.

To share data between elements you can create a StateObserver class, and extend your web components from that. A minimal implementation looks something like this:

// create a base class to handle state
class StateObserver extends HTMLElement {
	constructor () {
  	super()
    StateObserver.instances.push(this)
  }
	stateUpdate (update) {
  	StateObserver.lastState = StateObserver.state
    StateObserver.state = update
    StateObserver.instances.forEach((i) => {
    	if (!i.onStateUpdate) return
    	i.onStateUpdate(update, StateObserver.lastState)
    })
  }
}

StateObserver.instances = []
StateObserver.state = {}
StateObserver.lastState = {}

// create a web component which will react to state changes
class CustomReactive extends StateObserver {
	onStateUpdate (state, lastState) {
  	if (state.someProp === lastState.someProp) return
    this.innerHTML = `input is: ${state.someProp}`
  }
}
customElements.define('custom-reactive', CustomReactive)

class CustomObserved extends StateObserver {
	connectedCallback () {
  	this.querySelector('input').addEventListener('input', (e) => {
    	this.stateUpdate({ someProp: e.target.value })
    })
  }
}
customElements.define('custom-observed', CustomObserved)
<custom-observed>
  <input>
</custom-observed>
<br />
<custom-reactive></custom-reactive>

fiddle here

I like this approach because:

  • no dom traversal to find data- properties
  • no Object.observe (deprecated)
  • no Proxy (which provides a hook but no communication mechanism anyway)
  • no dependencies, (other than a polyfill depending on your target browsers)
  • it's reasonably centralised & modular... describing state in html, and having listeners everywhere would get messy very quickly.
  • it's extensible. This basic implementation is 20 lines of code, but you could easily build up some convenience, immutability, and state shape magic to make it easier to work with.
3
votes

Changing an element's value can trigger a DOM event. Listeners that respond to events can be used to implement data binding in JavaScript.

For example:

function bindValues(id1, id2) {
  const e1 = document.getElementById(id1);
  const e2 = document.getElementById(id2);
  e1.addEventListener('input', function(event) {
    e2.value = event.target.value;
  });
  e2.addEventListener('input', function(event) {
    e1.value = event.target.value;
  });
}

Here is code and a demo that shows how DOM elements can be bound with each other or with a JavaScript object.

3
votes

Bind any html input

<input id="element-to-bind" type="text">

define two functions:

function bindValue(objectToBind) {
var elemToBind = document.getElementById(objectToBind.id)    
elemToBind.addEventListener("change", function() {
    objectToBind.value = this.value;
})
}

function proxify(id) { 
var handler = {
    set: function(target, key, value, receiver) {
        target[key] = value;
        document.getElementById(target.id).value = value;
        return Reflect.set(target, key, value);
    },
}
return new Proxy({id: id}, handler);
}

use the functions:

var myObject = proxify('element-to-bind')
bindValue(myObject);
3
votes

Here's an idea using Object.defineProperty which directly modifies the way a property is accessed.

Code:

function bind(base, el, varname) {
    Object.defineProperty(base, varname, {
        get: () => {
            return el.value;
        },
        set: (value) => {
            el.value = value;
        }
    })
}

Usage:

var p = new some_class();
bind(p,document.getElementById("someID"),'variable');

p.variable="yes"

fiddle: Here

2
votes

I have gone through some basic javascript example using onkeypress and onchange event handlers for making binding view to our js and js to view

Here example plunker http://plnkr.co/edit/7hSOIFRTvqLAvdZT4Bcc?p=preview

<!DOCTYPE html>
<html>
<body>

    <p>Two way binding data.</p>

    <p>Binding data from  view to JS</p>

    <input type="text" onkeypress="myFunction()" id="myinput">
    <p id="myid"></p>
    <p>Binding data from  js to view</p>
    <input type="text" id="myid2" onkeypress="myFunction1()" oninput="myFunction1()">
    <p id="myid3" onkeypress="myFunction1()" id="myinput" oninput="myFunction1()"></p>

    <script>

        document.getElementById('myid2').value="myvalue from script";
        document.getElementById('myid3').innerHTML="myvalue from script";
        function myFunction() {
            document.getElementById('myid').innerHTML=document.getElementById('myinput').value;
        }
        document.getElementById("myinput").onchange=function(){

            myFunction();

        }
        document.getElementById("myinput").oninput=function(){

            myFunction();

        }

        function myFunction1() {

            document.getElementById('myid3').innerHTML=document.getElementById('myid2').value;
        }
    </script>

</body>
</html>
2
votes
<!DOCTYPE html>
<html>
<head>
    <title>Test</title>
</head>
<body>

<input type="text" id="demo" name="">
<p id="view"></p>
<script type="text/javascript">
    var id = document.getElementById('demo');
    var view = document.getElementById('view');
    id.addEventListener('input', function(evt){
        view.innerHTML = this.value;
    });

</script>
</body>
</html>
0
votes

It is very simple two way data binding in vanilla javascript....

<input type="text" id="inp" onkeyup="document.getElementById('name').innerHTML=document.getElementById('inp').value;">

<div id="name">

</div>