This article introduces databind approach to a solution - you will see a detailed explanation with examples. Moreover, it would mention the basic concepts of how to create events on a pure object. You will find advice to build a clear structured solution, and how to implement a small Web JS application.
Introduction
It is relatively easy to attach and detach events to any element over JavaScript. The more challenging task is when the application becomes bigger and messy with lots of attach/detach constructions. Eventually, it becomes hard to manage everything, or figure out the purpose of some event handler. There should be a purpose, a plan for event handlers. Though, when trying to update or find an issue within the solution would become just a task to fix the case, instead of spending much time to figure out what are all those attached event handlers for.
A Few Words About Code Planning and Good Architecture
Regularly occurring code constructions can be Ajax requests, attach/detach events, show/hide views, etc. They are easy to determine since they often come in pairs. Or there could be enough repetition that the tension appears to over-code all repeated similarities into the high-level solution. Sometimes, it is a good approach. Sometimes, it could lead to too many restrictions, though, thinking that having all those parts just copy/pasted with slightly modified changes seemed to look like not a bad idea. It could be a boring task. When it comes time to investigate the code and grep (find) all values, all similar code parts can be easily located over lookup search terms or regex.
It is always good to have a plan. Even if it is stupid. Even when there is no plan, still it is a plan. A plan can occur in the middle of code writing. The good solution, the finished one is the solution with the plan/idea that was from the beginning or emerged during implementation.
The Big Structure for the Small JS Application
It can be skipped by impatient readers. The main part is after the title "Introducing data binding (more rules/more planning)". To make the subject easy to comprehend, let's think about a small application. How can it be structured and implemented? What are the participants? Where is the place that would allow to give more attention to data binding and less focus on the other stuff.
There should be a place for participants. Participants would communicate with each other by means of exchange data. We will call it an application. Think about a place for a repetitive code that can be over-coded. I'm talking about utilities. And there should be a couple of participants that would keep data binding. Probably, something more like templates to build UI.
The simple structure of the application that would help to focus on the data binding implementation.
class App {
constructor(selector) {
}
initialize() {
}
initialize$App(el) {
}
logModelState(model) {
}
bind(formModel) {
}
remove() {
}
}
setTimeout(() => {
const app = new App('body');
const model = new TimerModel();
app.initialize();
app.bind(model);
});
The most important part of the application is user input. The look of the application. UI will allow us to read and process user input. Let's build a small UI with a couple of fields and basic debugging. The UI would look something like that.
The HTML part of the application:
const mainTemplate = data => `
<div>
<div class="form">
</div>
<pre class="model-state">
</pre>
</div>
`;
const formTemplate = data => `
<div class="form-item"><label>Enter your name:
<input type="text" class="name" size="40" value=""/></label></div>
<div class="form-item"><label>Base64 code name:
<input type="text" class="output" size="40" value=""/></label></div>
<div class="form-item"><span class="current-time"> </span></div>
`;
Since it is communication, there should be at least two participants. The first that would present the form and the second that would process the input from UI and show the processed results. And some debug information.
The View and the Model:
class FormView {
constructor(selector) {
}
initialize() {
}
initialize$FormView(el) {
}
bind(model) {
}
unbind() {
}
remove() {
}
}
class TimerModel {
constructor() {
}
initialize() {
}
processForm() {
}
remove() {
utils.getResult(this, () => this.processFormRemove);
clearInterval(this.timer);
}
}
The main structure is finished. Still, there is nothing visible on UI. It is time to think about internal implementation. First - show UI. UI will come from templates. There should be a tool that would deal with rendering HTML. If there is UI, then we need to locate DOM elements, attach/detach event handlers. It should be simple, versatile, maybe with new fresh ideas.
const utils = {
html(el, html) {
el.innerHTML = html;
},
el(selector, inst = document) {
if (!selector) {
return null;
}
if ('string' === typeof selector) {
return inst.querySelector(selector);
}
return selector;
},
on(inst, selector, eventName, fn) {
const handler = function (evnt) {
if (evnt.target.matches(selector)) {
fn(evnt);
}
}
inst.addEventListener(eventName, handler);
return function () {
inst.removeEventListener(eventName, handler);
}
},
getResult(inst, getFn) {
const fnOrAny = getFn && getFn();
if (typeof fnOrAny === 'function') {
return fnOrAny.call(inst);
}
return fnOrAny;
}
};
Now we can build UI. Let's think about the constructors from classes App
and FormView
. There is no point for those classes without UI. They are accepting one selector
argument within the method signature.
Lets put this line this.el = utils.el(selector);
into their constructors:
constructor(selector) {
this.el = utils.el(selector);
}
The small rule. In the case it is view class that works with DOM element, we will keep DOM element in the el
property. Since there is el
variable on the instance, let's use it to render HTML. And since it is about rendering HTML, I'm not sure that it would be a great idea to keep it inside of the constructor. It would be better to have separate initialize
method just for that purpose.
The updated initialize
method of the App
class:
initialize() {
utils.html(this.el, mainTemplate({}));
this.initialize$App(this.el);
this.form.initialize();
}
The updated initialize
method for the FormView
class:
initialize() {
utils.html(this.el, formTemplate({}));
this.initialize$FormView(this.el);
}
I guess you have noticed two more new methods calls: initialize$App
and initialize$FormView
. Do you remember, I've mentioned a plan? Good or bad it doesn't matter. This is it. It is hard to judge now how is it good to build properties for DOM elements within initialize
method. I have decided to keep such commands separated. In case it is a bad plan, I can rethink and extract those methods into the parent initialize
method. In case it is good - such structure will be preserved.
Given below are implementations of initialize$App
and initialize$FormView
methods:
initialize$FormView(el) {
this.name = utils.el('.name', el);
this.output = utils.el('.output', el);
this.currentTime = utils.el('.current-time', el);
}
...
initialize$App(el) {
this.form = new FormView(utils.el('.form', el));
this.modelState = utils.el('.model-state', el);
}
Both methods are responsible to make more properties on the instance. And it is the only place that would build/update such properties. Any time view will be (re)rendered, those methods can be called after and refresh properties with new DOM elements.
The application can be started and it should show a simple form with two fields/text-boxes. Nothing more right now. The most interesting part will come when data binding will be introduced .
Introducing Data Binding (More Rules/More Planning)
The first task of data binding is to monitor changes. In the example, changes will come from the view/UI to the model and changes will come from the model to view. The simple part is to adjust the solution with receiving changes from the view/UI.
Take into account that it is not about a one-time change. There is input/text-box. Text can be entered, deleted, copy/pasted many times till the final version will be accepted by the form. Text-box can have a default value. There could be future code updates, e.g., introduce fields validation, updates to UI design. The model for the view can be replaced with another model with different initial values in fields. The view should be nicely decoupled from the model as well. The point is that view should be sufficient by itself even if it is not attached to the model.
The focus is on data that comes from UI instead of the way in which that data can be enquired from the form. Next rule. The data can be read/written by getters/setters. Two benefits and one drawback. Easy to read/write values from fields, do future updates into UI. E.g., replace input text by complex dropdown with predefined values and search suggestions. It would help to keep binding rules clear. And drawback, need to write more code for getters and setters.
If it is about more code, there is another rule. Let's keep getters/setters pairs between constructor
and initialize
methods. With that approach, it would be easy to locate getters/setters and understand that they are getters/setters but nothing other.
The getters and setters for both inputs, "Enter your name" and "Base64 code name".
Part of the class FormView
:
getName() {
return this.name.value;
}
setName(val) {
if (val !== this.name.value) {
this.name.value = val;
}
}
getOutput() {
return this.output.value;
}
setOutput(val) {
if (val !== this.output.value) {
this.output.value = val;
}
}
setCurrentTime(val) {
if (val != this.currentTime.innerText) {
this.currentTime.innerText = val;
}
}
It is easy to read and write data to UI, there is no need to pass some variables that contain changed data. It would be enough to pass the view and refer to data over getters and setters. That opens another possibility. Now DOM event handlers can be written in a simpler way.
And if to talk about event handlers, there will be a lot of them. The good clue for the next rule. This time, it is naming. Let's name event handlers according to this pattern: on<EventName><FieldName>
, e.g., onInputName
. This event handler will pass the value from the view/UI to the model.
View to Model event handlers. One more rule: Let's keep all handlers after the unbind
method in the class FormView
.
onInputName() {
this.model.prop('name', this.getName());
}
onInputOutput() {
this.model.prop('output', this.getOutput());
}
this.model
- This is something that I will regret with the example. It makes a strong coupling between view and model. Since it is about data binding, let's keep it coupled with a form for the seek of brevity. Obviously, for the real application, the view should be decoupled from the model. This is a good candidate to optimize in the end.
Time for the first data binding commands. Attach event handlers to the related elements. Again rule. Let's keep all events that we are attaching and that are a part of the data binding within bind
method.
Updated bind
method:
bind(model) {
this.onInputNameRemove = utils.on(this.el, '.name', 'input', () => this.onInputName());
this.onInputOutputRemove =
utils.on(this.el, '.output', 'input', () => this.onInputOutput());
}
this.onInputNameRemove
and this.onInputOutputRemove
- are methods to detach event handlers from the form. They will be called in the unbind
method.
Updated unbind
method:
unbind() {
utils.getResult(this, () => this.onInputNameRemove);
utils.getResult(this, () => this.onInputOutputRemove);
}
The way to update data from view/UI to model is ready. It was easy, because DOM elements have addEventListener
/removeEventListener
methods. The model is just a class. Data binding demands that the model should have a way to notify about change state.
To the convenient use it would be great if model would have on
method like it is for DOM elements in utils.on
. And another important method is trigger
, over that method the model will notify any listening participants about changes. Although, it would be great if any class can implement notify interface. That would be useful for the future. It should be small but enough to accomplish data binding.
A couple of methods below:
function dispatcher() {
const handlers = [];
return {
add(handler) {
if (!handler) {
throw new Error('Can\'t attach to empty handler');
}
handlers.push(handler);
return function () {
const index = handlers.indexOf(handler);
if (~index) {
return handlers.splice(index, 1);
}
throw new Error('Ohm! Something went wrong with
detaching unexisting event handler');
};
},
notify() {
const args = [].slice.call(arguments, 0);
for (const handler of handlers) {
handler.apply(null, args);
}
}
}
}
function initEvents() {
const args = [].slice.call(arguments, 0);
const events = {};
for (const key of args) {
events[key] = dispatcher();
}
return {
on(eventName, handler) {
return events[eventName].add(handler);
},
trigger(eventName) {
events[eventName].notify();
}
};
}
With such a tool, it becomes possible to create events, attach/detach event listeners, notify about changes. The first candidate is the model. And the second is the FormView
.
The code below adjusts the Model
to allow notify about changes:
class TimerModel {
constructor() {
const{ on, trigger } = initEvents(this,
'change:name',
'change:output',
'change:time'
);
this.on = on;
this.trigger = trigger;
this.state = {
name: 'initial value',
output: '',
time: new Date()
};
this.initialize();
}
initialize() {
this.timer = setInterval(() => this.prop('time', new Date()), 1000);
this.processFormRemove = this.on('change:name', () => this.processForm());
}
prop(propName, val) {
if (arguments.length > 1 && this.state.val !== val) {
this.state[propName] = val;
this.trigger('change:' + propName);
}
return this.state[propName];
}
processForm() {
setTimeout(() => {
this.prop('output', btoa(this.prop('name')));
});
}
remove() {
utils.getResult(this, () => this.processFormRemove);
clearInterval(this.timer);
}
}
Let's make the same thing with FormView
as well:
class FormView {
constructor(selector) {
const { on, trigger } = initEvents(this,
'change:model'
);
this.on = on;
this.trigger = trigger;
this.el = utils.el(selector);
}
...
Now FormView
can be adjusted with more data binding commands. This time to transfer data from the model to view. The nice thing to keep in mind is that any repetitive stuff is hard to extract into the "utility
" library, at least try to keep in the one place. Try hard to not spread over the whole component solution. The bind
, unbind
methods and following event handlers methods would be better to keep as close to each other as possible. That would help for future maintenance, maybe refactoring.
The full version of bind
, unbind
methods and event handlers of the FormView
class:
bind(model) {
this.onInputNameRemove = utils.on(this.el, '.name', 'input', () => this.onInputName());
this.onInputOutputRemove = utils.on
(this.el, '.output', 'input', () => this.onInputOutput());
this.syncNameRemove = model.on('change:name', () => this.syncName());
this.syncOutputRemove = model.on('change:output', () => this.syncOutput());
this.syncCurrentTimeRemove = model.on('change:time', () => this.syncCurrentTime());
}
unbind() {
utils.getResult(this, () => this.onInputNameRemove);
utils.getResult(this, () => this.onInputOutputRemove);
utils.getResult(this, () => this.syncNameRemove);
utils.getResult(this, () => this.syncOutputRemove);
utils.getResult(this, () => this.syncCurrentTimeRemove);
}
onInputName() {
this.model.prop('name', this.getName());
}
onInputOutput() {
this.model.prop('output', this.getOutput());
}
syncName(evnt) {
this.setName(this.model.prop('name'));
}
syncOutput(evnt) {
this.setOutput(this.model.prop('output'));
}
syncCurrentTime() {
this.setCurrentTime(this.model.prop('time'));
}
Just one more impossible step left. That makes all that work. The first thing that comes to my mind is to make more methods. It would be setModel
and syncModel
methods. The first one is to set a model and trigger change model event on the FormView
. The second one is the event handler. And a small update into the constructor method with attach that event handler. Such an event handler is not a part of the data binding but will play an important role in the whole FormView
class.
More changes to class FormView
:
class FormView {
constructor(selector) {
...
this.sycnModelRemove = this.on('change:model', () => this.syncModel());
}
...
setModel(val) {
if (val !== this.model) {
this.model = val;
this.trigger('change:model');
}
}
...
syncModel() {
this.unbind();
this.setName(this.model.prop('name'));
this.setOutput(this.model.prop('output'));
this.model && this.bind(this.model);
}
...
And the cherry on the pie is updated class "App::bind
" method to run all that beast:
class App {
...
bind(formModel) {
this.form.setModel(formModel);
}
...
If everything was right, there will be a form with two inputs. Typing in the first input would update the second input with the base64
encoded string from the first input. The first input at the beginning will have "initial value" as the initial value.
The form will show the current time. Something like this.
Now it is time to think about the made job and make some conclusions:
- Two-way data binding in pure JavaScript is possible.
- If data binding is based on getters/setters, there will be a lot of them.
- Every field would require at least two event listeners for two-way data binding.
- There should be a good implementation for the Observer design pattern.
- Code could become messy, it is good to set some rules into the code structure to help future developers to not mess with the implementation.
- Adjusting two-way data binding feels like extra work with tension to extract repeating parts into a separate library. But how hard is it to try extract that would give less effective library versus just keep binding commands within the one place in the component.
- Having good data binding will help to decouple the view from the model.
And if we talk about decoupling. There is my regret part. Where the model is coupled with the view. It was done just to focus on data binding commands. For the most patient one that passed all that way till the end. Here is an example of how to decouple the model from the view.
Let's make some code cleaning tasks in the class FormView
. Remove that last part with event handler in the constructor, setModel
and syncModel
methods. Then clean up the constructor. And update setModel
and bind method with the code from below:
class FormView {
constructor(selector) {
this.el = utils.el(selector);
}
...
setModel(model) {
this.unbind();
if (!model) {
return;
}
this.setName(model.prop('name'));
this.setOutput(model.prop('output'));
model && this.bind(model);
}
...
bind(model) {
this.onInputNameRemove = utils.on(this.el, '.name', 'input',
() => model.prop('name', this.getName()));
this.onInputOutputRemove = utils.on(this.el, '.output', 'input',
() => model.prop('output', this.getOutput()));
this.syncNameRemove = model.on('change:name', () => this.setName(model.prop('name')));
this.syncOutputRemove = model.on('change:output',
() => this.setOutput(model.prop('output')));
this.syncCurrentTimeRemove = model.on('change:time',
() => this.setCurrentTime(model.prop('time')));
}
...
Now the form is less coupled with the model. And all data binding commands are consolidated within the only bind
method.
Here is the final result in jsfiddle.
History
- 12th April, 2020: Initial version