Implementing data binding with native JavaScript

Writing your own implementation of data binding can be done fairly easily using native JavaScript. If you don't feel the need to use a comprehensive framework or library for your application and simply want the benefit of the data binding design pattern baked in, using native JavaScript to implement it is a logical course to take. This will provide you with several benefits, including the following:

  • You will understand how the data binding actually works under the hood
  • You will have a less bloated frontend that doesn't include extraneous library code that you may not even use
  • You won't be pigeonholed into an architecture defined by a particular framework when all you want is the added benefit of data binding

Object getters and setters

The Object type in JavaScript has native get and set properties that can be used as getters and setters for any property name on a particular object. A getter is a method that returns a dynamically computed value from an object, and a setter is a method that is used to pass a value to a given property on an object as if you were assigning it that value. When a setter is defined and passed a value, the property name for that setter cannot actually hold a value itself; however, it can be used to set the value on a completely different variable.

The get and set properties default to undefined, just like any unassigned property on an object, so they can easily be defined as functions for any user-defined object without affecting JavaScript's native Object prototype. This can be a powerful tool when used in an appropriate manner within an intuitive design pattern such as data binding.

The object initializer

Getters and setters can be defined for an object using an object initializer, which is most commonly performed by defining an object in literal notation. For example, suppose we want to create a getter and a setter for the firstName property on an object named user:

var firstName = 'Udis'; 
var user = { 
    get firstName() { 
        return firstName; 
    }, 
    set firstName(val) { 
        firstName = val; 
    } 
}; 

In this instance, we can use the user.firstName property to get and set the value for the firstName variable by simply using standard object literal syntax:

console.log(user.firstName); // Returns "Udis" 
user.firstName = 'Jarmond'; 
console.log(user.firstName); // Returns "Jarmond" 
console.log(firstName); // Returns "Jarmond" 

In this example, setting user.firstName = 'Jarmond' does not actually change the value of the user.firstName property; rather, it calls the property's defined setter method and instead sets the value for the standalone firstName variable.

The Object.defineProperty() method

It may often be the case that you would like to modify an existing object to provide data binding for that object in your application. To do this, the Object.defineProperty() method can be used to add the getter and setter for a particular property on a predefined object:

var user = {}; 
Object.defineProperty(user, 'firstName', { 
    get: function() { 
        return firstName; 
    } 
    set: function(val) { 
        firstName = val; 
    }, 
    configurable: true, 
    enumerable: true 
}); 

This method takes the object you want to define a property for as the first argument, the property name you are defining as the second argument, and a descriptor object as the third argument. The descriptor object allows you to define the getter and setter for the property using the get and set key names, and it additionally allows some other keys to further describe the property.

The configurable key, if true, allows the property's configuration to be changed and the property itself to be deleted. It defaults to false. The enumerable key, if true, allows the property to be visible when iterating over the parent object. It also defaults to false.

Using Object.defineProperty() is a more concise way to declare the getter and setter for an object's property because you can explicitly configure the behavior for that property, in addition to being able to add the property to a predefined object.

Designing a getter and setter data binding pattern

Now we can take this example further by creating a two-way binding between a DOM element and the user object for which we have defined a getter and setter. Let's consider a text input element that is prepopulated with the firstName value initially when the page loads:

<input type="text" name="firstName" value="Jarmond"> 

Now we can define our getter and setter based on the value of this input so that there is a reactive binding between the Model and the View:

var firstName = document.querySelector('input[name="firstName"]'); 
var user = {}; 
Object.defineProperty(user, 'firstName', { 
    get: function() { 
        return firstName.value; 
    }, 
    set: function(val) { 
       firstName.value = val; 
    }, 
    configurable: true, 
    enumerable: true 
}); 

If you create a page with the input element and run the code above, you can then use the developer console in your browser to set the value of user.firstName and see it automatically update in the DOM for the value of the input element:

user.firstName = 'Chappy'; 

Additionally, if you change the value in the text input and then check the value of the user.firstName property in the developer console, you will see that it reflects the changed value of the input. With this simple use of a getter and setter, you have architecturally implemented two-way data binding with native JavaScript.

Synchronizing data in the View

To further extend this example so that representations of the Model in the View always remain synchronized and work much like the Rivets.js data binding pattern, we can simply add an oninput event callback to our input to update the DOM in the desired fashion:

firstName.oninput = function() { 
    user.firstName = user.firstName; 
}; 

Now, if we want other places in the DOM where this data is represented to be updated upon changing the value of this input, all we need to do is add the desired behavior to the setter for the property. Let's use a custom HTML attribute called data-bind to convey the property's representation in the DOM outside of the text input itself.

First, create a static file with the following HTML:

<p> 
    <label> 
        First name:  
        <input type="text" name="firstName" value="Udis"> 
    </label> 
</p> 

Then, below the HTML and just before the closing </body> tag of your document, add the following JavaScript within <script> tags:

var firstName = document.querySelector('input[name="firstName"]'); 
var user = {}; 
Object.defineProperty(user, 'firstName', { 
    get: function() { 
        return firstName.value; 
    }, 
    set: function(val) { 
        var list = document.querySelectorAll( 
            '[data-bind="firstName"]' 
        ), i; 
        for (i = 0; i < list.length; i++) { 
            list[i].innerHTML = val; 
        } 
        firstName.value = val; 
    }, 
    configurable: true, 
    enumerable: true 
}); 
user.firstName = user.firstName; 
firstName.oninput = function() { 
    user.firstName = user.firstName; 
}; 

Now load the page in your browser and observe that the <strong data-bind="firstName"> element will be populated with the name Udis from the value of the input. This is achieved by calling the setter for the user.firstName property and assigning it to its corresponding getter as user.firstName = user.firstName. This may seem redundant, but what is actually occurring here is the code defined in the setter method is being executed with the given value from the text input, which is obtained from the getter. The setter looks for any element on the page with the data-bind property set to firstName and updates that element's content with the firstName value from the input, which is represented in the model as user.firstName.

Next, place your cursor in the text input and change the value. Notice that the name represented within the <strong> element changes as you type, and each representation is in sync with the model. Finally, use your developer console to update the value of the model:

user.firstName = 'Peebo'; 

Notice that the representations both in the text input and the <strong> element are automatically updated and in sync. You have successfully created a two-way data binding and View synchronization design pattern using a small amount of native JavaScript.

Abstracting the design pattern to a reusable method

You can further abstract your data binding design pattern by creating a method that can be used to apply this behavior to a property for any predefined object:

function dataBind(obj, prop) { 
    var input = document.querySelector('[name="' + prop + '"]'); 
    input.value = obj[prop] || input.value; 
    Object.defineProperty(obj, prop, { 
        get: function() { 
            return input.value; 
        }, 
        set: function(val) { 
            var list = document.querySelectorAll( 
                '[data-bind="' + prop + '"]' 
            ), i; 
            for (i = 0; i < list.length; i++) { 
                list[i].innerHTML = val; 
            } 
            input.value = val; 
        }, 
        configurable: true, 
        enumerable: true 
    }); 
    obj[prop] = obj[prop]; 
    input.oninput = function() { 
        obj[prop] = obj[prop]; 
    }; 
} 

Here, we have created a method called dataBind, which takes an object and a property as its arguments. The property name is used as an identifier for elements in the DOM that are to be bound to the Model:

// For the input 
var input = document.querySelector('[name="' + prop + '"]'); 
// For other elements 
var list = document.querySelectorAll('[data-bind="' + prop + '"]'); 

Next, simply define an object and call the dataBind method on it, additionally passing in the property name you want to bind to the DOM. This method also allows you to set the initial value for the property in the Model, and it will be reflected in the View upon binding if it is set. Otherwise, it will display the value set on the input itself, if any:

var user = {}; 
user.firstName = 'Peebo'; 
dataBind(user, 'firstName'); 

If you modify the code in the page you just created to use the abstracted dataBind method, you will see that it works exactly as before, but it can now be reused to bind multiple object properties with multiple corresponding elements in the DOM. This pattern can certainly be further abstracted and combined with a modeling pattern in which it could be used as a powerful data binding layer within a JavaScript SPA. The open source library inbound.js is a good example of this pattern taken to the next level. You can learn more about it at inboundjs.com.

Accounting for DOM mutations

One downfall of the previous example, when it comes to View synchronization, is that only user input will trigger the setting of the Model from the View. If you want comprehensive, two-way data binding in which any changes to bound values in the View sync to their respective Model properties, then you must be able to observe DOM mutations, or changes, by any means.

Let's take a look at the previous example again:

var user = {}; 
user.firstName = 'Peebo'; 
dataBind(user, 'firstName'); 

Now if you edit the value of the text input, the firstName property on the Model will update, and the <strong data-bind="firstName"> element's contents will be updated as well:

<input type="text" name="firstName" value="Jarmond"> 
 
console.log(user.firstName); // returns "Jarmond" 

Now let's instead use the developer console and change the <strong data-bind="firstName"> element's innerHTML:

document.querySelector('strong[data-bind="firstName"]') 
    .innerHTML = 'Udis'; 

After doing this, you will notice that the value of the input has not updated, and neither has the Model data:

console.log(user.firstName); // returns "Jarmond" 

The DOM mutation you created by using the console has now broken your two-way data binding and View synchronization. Fortunately, there is a native JavaScript constructor that can be used to avoid this pitfall.

MutationObserver

The MutationObserver constructor provides the ability to observe changes to the DOM no matter where they are triggered from. In most cases, user input is likely sufficient for triggering Model updates, but if you are building an application that may have DOM mutations triggered from other sources, such as data being pushed via Websockets, you may want to sync those changes back to your Model.

MutationObserver works much like the native addEventListener by providing a special type of listener that triggers a callback upon DOM mutation. This event type is unique in that it is not often triggered by direct user interaction, unless the developer console is being used to manipulate the DOM. Instead, application code is typically what will be updating the DOM, and this event is triggered directly by those mutations.

A simple MutationObserver can be instantiated as follows:

var observer = new MutationObserver(function(mutations) { 
  mutations.forEach(function(mutation) { 
    console.log(mutation); 
  });     
});  

Next, a configuration must be defined to pass to the observe method of the new observer object:

    var config = { 
        attributes: true, 
        childList: true, 
        characterData: true 
    }; 
 

This object is called MutationObserverInit. It defines special properties that are used by the MutationObserver implementation to specify how closely an element is to be observed. At least one of attributes, childList, or characterData must be set to true, or an error will be thrown:

  • attributes: Tells the observer whether mutations to the element's attributes should be observed or not
  • childList: Tells the observer whether the addition and removal of the element's child nodes should be observed or not
  • characterData: Tells the observer whether mutations to the element's data should be observed or not

There are also four additional, but optional, MutationObserverInit properties that can be defined:

  • subtree: If true, tells the observer to watch for mutations to the element's descendants, in addition to the element itself
  • attributeOldValue: If true in conjunction with attributes set to true, tells the observer to save the element attributes' old values before mutation.
  • characterDataOldValue: If true in conjunction with characterData set to true, tells the observer to save the element's old data values before mutation.
  • attributeFilter: An array specifying attribute names that should not be observed in conjunction with attributes set to true.

With the configuration defined, an observer can now be called on a DOM element:

var elem = document.querySelector('[data-bind="firstName"]'); 
observer.observe(elem, config); 

With this code in place, any mutations to the element with the attribute data-bind="firstName" will trigger the callback defined in the observer object's instantiation of the MutationObserver constructor, and it will log the mutation object passed to the iterator.

Extending dataBind with MutationObserver

Now let's further extend our dataBind method with the MutationObserver constructor by using it to trigger callbacks when elements with the data-bind attribute are mutated:

function dataBind(obj, prop) { 
    var input = document.querySelector('[name="' + prop + '"]'); 
    var observer = new MutationObserver(function(mutations) { 
        mutations.forEach(function(mutation) { 
            var val = mutation.target.innerHTML; 
            if (obj[prop] !== val) { 
                console.log( 
                    'Inequality detected: "' +  
                    obj[prop] + '" !== "' + val + '"' 
                ); 
                obj[prop] = mutation.target.innerHTML; 
            } 
        }); 
    }); 
    var config = { 
        attributes: true, 
        childList: true, 
        characterData: true 
    }; 
    var list = document.querySelectorAll( 
        '[data-bind="' + prop + '"]' 
    ), i; 
    for (i = 0; i < list.length; i++) { 
        observer.observe(list[i], config); 
    } 
    input.value = obj[prop] || input.value; 
    Object.defineProperty(obj, prop, { 
        get: function() { 
            return input.value; 
        }, 
        set: function(val) { 
            var list = document.querySelectorAll( 
                '[data-bind="' + prop + '"]' 
            ), i; 
            for (i = 0; i < list.length; i++) { 
                list[i].innerHTML = val; 
            } 
            input.value = val; 
        }, 
        configurable: true, 
        enumerable: true 
    }); 
    obj[prop] = obj[prop]; 
    input.oninput = function() { 
        obj[prop] = obj[prop]; 
    }; 
} 

The MutationObserver constructor takes a callback function as its only parameter. This callback is passed a mutations object, which can be iterated over to define callbacks for each mutation:

var observer = new MutationObserver(function(mutations) { 
    mutations.forEach(function(mutation) { 
        var val = mutation.target.innerHTML; 
        if (obj[prop] !== val) { 
            console.log( 
                'Inequality detected: "' +  
                obj[prop] + '" !== "' + val + '"' 
            ); 
            obj[prop] = mutation.target.innerHTML; 
        } 
    }); 
}); 
 

Note that in the MutationObserver instantiation callback, we perform an inequality comparison of the bound Model property to the mutation.target.innerHTML, which is the content of the DOM element being observed, before we set the Model property. This is important because it provides that we only set the bound Model property when there is a DOM mutation triggered directly on this particular DOM node, and not as a result of a setter. If we did not perform this check, all setters would trigger the callback, which calls the setter again, and infinite recursion would ensue. This is, of course, not desirable.

Using the new version of the dataBind method, test the HTML page in a browser again and update the input value:

<input type="text" name="firstName" value="Chappy"> 
 
console.log(user.firstName); // returns "Chappy" 

Next, use the developer console to change the bound Model property and you will see it update in the DOM for both the input and the <strong data-bind="firstName"> element, as originally expected:

user.firstName = 'Peebo'; 

Finally, use the developer console to change the innerHTML of the <strong data-bind="firstName"> element and trigger a mutation event:

document.querySelector('strong[data-bind="firstName"]') 
    .innerHTML = 'Udis'; 

This time, you will see the value of the input element update as well. This is because the mutation event was triggered and detected by the observer object, which then fired the callback function. Within that callback function, the obj[prop] !== val comparison was made and found to be true, so the setter was called on the new value, subsequently updating the input value and the value returned from the user.firstName property:

console.log(user.firstName); // returns "Udis" 

You have now implemented two-way data binding and comprehensive view synchronization using native getters and setters and the MutationObserver constructor. Keep in mind that the examples given here are experimental and have not been used in a real-world application. Care should be taken when employing these techniques in your own application, and testing should be paramount.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.21.12.140