topic: JavaScript by Milos Protic relates to: Web Development on May, 07 2019
Vanilla JS Data Binding With Classes From Scratch
Overview
Font-End world, and programming world overall actually, is full of useful frameworks and libraries solving a tremendous amount of issues we face on a daily basis, and this is the reason why they are so popular.
The main benefits of using a framework are that we don't have to redo the work we did on our previous projects and we retain a strong focus on the business logic of the project we are working on. Not to mention the cost reduction for the companies since a developer is a lot cheaper than an architect. The speed of development as well, which is directly linked to the costs...etc. Many other benefits can be accounted for here, but we won't do that since this post has a different perspective.
Not going further from the framework boundaries has a set of consequences. The biggest one is that we stop being developers (we miss out the cool stuff) and become only advanced users of a tool someone created for us. This is the situation we have nowadays and many people think that if they know Angular, Vue or React they are programmers. This is not true, because in that case, all you know is how to set up and configure a tool that works everything for you.
This is all that this post is about. It will cover a topic that many people took for granted since it is so common nowadays, a data binding. I come from an era where data binding wasn't deeply integrated as it is today and there were times when I did everything manually.
Our simple data binding mechanism will have the following classes:
Binding
- represents the binding directiveBinder
- represents the parser of our directivesTextBindingHandler
- represents the text data binding handlerValueBindingHandler
- represents the two-way data binding handler
Ok, let's dive into it.
HTML
Consider the following html:
<!DOCTYPE html>
<html>
<head>
<title>Vanilla JS Data Binding</title>
</head>
<body>
<main>
<input placeholder="Enter name">
<input placeholder="Enter surname">
<br>
<br>
<span>Entered Person</span>
<br>
<span></span>
<span></span>
</main>
</body>
</html>
Now, let us say that we want, whenever a name
or surname
input field changes, to update the information about the entered person below. With Vue, for example, we would just put v-model
and v-text
directive without worring about it. But doesn't this intrigue you? Don't you wonder how does it work exactly? I surely was intrigued when I first saw the data binding.
Let us update our HTML and use our directives:
<!DOCTYPE html>
<html>
<head>
<title>Vanilla JS Data Binding</title>
</head>
<body>
<main>
<input data-bind="value: name" placeholder="Enter name">
<input data-bind="value: surname" placeholder="Enter surname">
<br>
<br>
<span>Entered Person</span>
<br>
<span data-bind="text: name"></span>
<span data-bind="text: surname"></span>
</main>
</body>
</html>
At this point, they won't do anything since we haven't created them yet. Let's start with the Binding
class which will be a representation of the data-bind
attribute.
JavaScript
In order to make data binding possible, we need to backup it with our language of choice, the JavaScript.
Note that this code is written in next-gen JavaScript and make sure to use one of the latest browsers
Binding Class
The Binding
class looks like this:
class Binding {
constructor(prop, handler, el) {
this.prop = prop;
this.handler = handler;
this.el = el;
}
bind() {
let bindingHandler = Binder.handlers[this.handler];
bindingHandler.bind(this);
Binder.subscribe(this.prop, () => {
bindingHandler.react(this);
});
}
setValue(value) {
Binder.scope[this.prop] = value;
}
getValue() {
return Binder.scope[this.prop];
}
}
Our Binding
class has three properties and three methods. The prop
property will hold the scope, or the viewmodel if you prefer, property name to which we want to bind our element. The handler
property will hold the handler key (value
or text
in our example) we've defined in our Binder
class and the el
property will hold the HTML element we've bound to.
The method bind
does all the magic. It takes the handler based on the provided key and triggers its internal bind
method. Also, it subscribes the binding to the scope property and attaches a callback to it for future updates.
Each binding handler must have
bind
andreact
method implementations which we will see later in this post.
Methods getValue
and setValue
retrieve and set the scope value for us respectively.
Binder Class
Let us move to the next class implementation, the Binder
class:
class Binder {
static setScope(scope) {
this.scope = scope;
}
static redefine() {
let keys = Object.keys(this.scope);
keys.forEach((key) => {
let value = this.scope[key];
delete this.scope[key];
Object.defineProperty(this.scope, key, {
get() {
return value;
},
set(newValue) {
const shouldNotify = value != newValue;
value = newValue;
if (shouldNotify) {
Binder.notify(key);
};
}
})
});
}
static subscribe(key, callback) {
this.subscriptions.push({
key: key,
cb: callback
});
}
static notify(key) {
const subscriptions = this.subscriptions.filter(
subscription => subscription.key == key
);
subscriptions.forEach(subscription => {
subscription.cb();
})
}
}
// create some static properties
Binder.subscriptions = [];
Binder.scope = {};
Binder.handlers = {
value: new ValueBindingHandler(),
text: new TextBindingHandler()
}
This class is going to be used by all our directives, therefore, the methods and properties are defined as static.
We have setScope
method. This method is called only once at the application startup. All it does is set up the scope (viewmodel) property of the class. A scope is an object to which we want to bind our view.
Another method called only once is the redefine
method. This method has high importance in our program. What it does is that it takes each property of the given viewmodel and redefine it as a reactive one. Without this, it wouldn't be possible to update the UI after our scope updates. The UI update is done via the notify
method. This method loops through all subscriptions of a specific scope property and executes the callback attached to it.
Remember the
bind
method on theBinding
class which attaches the callback
In the end, we have a subscribe
method which creates a new subscription for the given key/callback
pair.
A slightly complex solution would be to store the subscriptions on the actual scope property. This would require to convert them to more complicated observables meaning that our
get
override wouldn't return only a raw value, but this is a topic for another post.
The Handler Classes
In these classes, we specify what each directive should do initially and after the scope update. As mentioned earlier, we must implement bind
and react
methods. Let's start with the ValueBindingHandler
since it's a two-way binding and it has additional method implementation. The class looks like this:
class ValueBindingHandler {
bind(binding) {
binding.el.addEventListener('input', () => {
this.listener(binding);
});
this.react(binding);
}
react(binding) {
binding.el.value = binding.getValue();
}
listener(binding) {
let value = binding.el.value;
binding.setValue(value);
}
}
A two-way data binding is exactly what its name says. A binding in two directions. This means that when we update the scope property our bound HTML element must be updated, and vice versa, when we update our HTML element it must update the scope property. This behavior is achieved with an event listener. In our particular case, an input
handler is used.
Initially, bind
method is called and it's called only once at the application startup. This is done internally, you don't have to call it manually. In this method, we attach an event listener and set the initial value of the scope property to the HTML element (by calling this.react
).
Remember the
getValue
method on theBinding
class which returns the scope property value
The listener
method is executed whenever we update the input
value on our page and it sets the newly entered value to the scope
property.
Remember the
setValue
method on theBinding
class which sets the scope property value
In the react
method on the other hand, which is called every time when a scope property changes, we set the new value back to the HTML element.
You can update the scope property in the browser console for example. Open it and execute this:
Binder.scope.name = 'My Cool Name'
The last class in our example, TextBindingHandler
looks like this:
class TextBindingHandler {
bind(binding) {
this.react(binding);
}
react(binding) {
binding.el.innerText = binding.getValue();
}
}
This class is pretty straight-forward. It has two mandatory methods, bind
and react
which are called upon app initialization and after the scope updates respectively. Since this is a one-way binding on the text property, all we do here is set the innerText
of the element.
After setting up your page go ahead and try to update the input fields. The change should update the span text in real time.
Application Startup
In the end, we need to have a code connecting the dots together. An example app initialization looks something like this:
Binder.setScope({
name: 'John',
surname: 'Doe'
});
Binder.redefine();
const els = document.querySelectorAll('[data-bind]');
els.forEach(el => {
const expressionParts = el.getAttribute('data-bind').split(':');
const bindingHandler = expressionParts[0].trim();
const scopeKey = expressionParts[1].trim();
const binding = new Binding(scopeKey, bindingHandler, el);
binding.bind();
});
Also, don't forget to update the HTML element and include the scripts:
<!DOCTYPE html>
<html>
<head>
<title>Vanilla JS Data Binding</title>
</head>
<body>
<main>
<input data-bind="value: name" placeholder="Enter name">
<input data-bind="value: surname" placeholder="Enter surname">
<br>
<br>
<span>Entered Person</span>
<br>
<span data-bind="text: name"></span>
<span data-bind="text: surname"></span>
</main>
</body>
<script src="my-path/TextBindingHandler.js"></script>
<script src="my-path/ValueBindingHandler.js"></script>
<script src="my-path/Binder.js"></script>
<script src="my-path/Binding.js"></script>
<script src="my-path/App.js"></script>
</html>
After this action, everything should work like a charm.
The example above works only on root properties of the given viewmodel. Homework for you would be to update the code and make it possible to work with nested objects and properties. Also, consider implementing
unbind
mechanism, if you are interested. After a singleunbind
call, your view should be free again.
Conclusion
If you weren't intrigued before, hopefully, you are now, and I hope I've managed to bring close the mechanism behind the scenes of overwhelmingly popular data binding to you. Stop being shy and ask someone how something was done if you can't figure out by yourself (but do try it though before), and don't forget, there is no such thing as a stupid question, there are only stupid answers.
Thank you for reading and happy coding.
Subscribe to get the latest posts delivered right to your inbox