Have you ever wondered how a framework works?
When I discovered AngularJS after learning Jquery from so many years ago, AngularJs bed like it is a dark magic to me.
Later, Vue js came out, and when analyzing how it worked, I tried writing my own 2-way binding system.
In this article, I’ll show you how to write a Javascript framework with custom HTML elements, communication and 2-way binding.
How does communication work?
It would be great to start with an understanding of how the framework works. In fact, when you declare a new element in Vue js, the framework supports each of the properties (getters and setters) using the proxy design pattern.
Therefore, it will be able to detect property value changes either from input to code or from user input.
What does the proxy design pattern look like?
The idea behind the proxy design pattern is very simple is overloaded access to an object. A similar example in life is accessing your bank account.
For example, you cannot directly access your bank account balance and change as needed. You need to ask the authorized person, in this case the bank where you are sending money.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | var account = { balance: 5000 } // A bank acts like a proxy between your bank account and you var bank = new Proxy(account, { get: function (target, prop) { return 9000000; } }); console.log(account.balance); // 5,000 (your real balance) console.log(bank.balance); // 9,000,000 (the bank is lying) console.log(bank.currency); // 9,000,000 (the bank is doing anything) |
In the above example, when using the object bank to access the balance, the getters function is overloaded and it always returns 9,000,000 instead of the property’s value, even if the property doesn’t exist.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // Overload setter default function var bank = new Proxy(account, { set: function (target, prop, value) { // Always set property value to 0 return Reflect.set(target, prop, 0); } }); account.balance = 5800; console.log(account.balance); // 5,800 bank.balance = 5400; console.log(account.balance); // 0 (the bank is doing anything) |
By overloading the setters function, you can control its behavior. You can change the value into the set function, update other attributes instead, even don’t do anything.
Example of how it works
Now that you understand how the proxy design pattern works, let’s start writing your own Javascript framework
For simplicity, I will mimic the Angular Js syntax for implementation. Define a controller and bind the template to the controller action.
1 2 3 4 5 6 7 8 9 10 11 12 13 | <div ng-controller="InputController"> <!-- "Hello World!" --> <input ng-bind="message"/> <input ng-bind="message"/> </div> <script type="javascript"> function InputController () { this.message = 'Hello World!'; } angular.controller('InputController', InputController); </script> |
First, define a controller with properties. Then, use this controller in the template. Finally, use the ng-bind property to enable double-binding with the value of the element.
Assign the template and initialize the controller
To have properties for the bind we need to have a controller and define the properties. Therefore, you need to define the controller and include it in the framework.
During the controller definition, the framework looks for elements with the ng-controller attribute.
If it matches one of the declared controllers, it will create a new instance of that controller. That instance controller will be responsible for only part of this template.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | var controllers = {}; var addController = function (name, constructor) { // Store controller constructor controllers[name] = { factory: constructor, instances: [] }; // Look for elements using the controller var element = document.querySelector('[ng-controller=' + name + ']'); if (!element){ return; // No element uses this controller } // Create a new instance and save it var ctrl = new controllers[name].factory; controllers[name].instances.push(ctrl); // Look for bindings..... }; addController('InputController', InputController); |
Here is how to declare a controller manually. The object controller will not contain all of the controllers declared in the framework by calling addController.
For each controller, a factory function is saved to initialize the new controller as needed. The framework also stores each new instance of the same controller used in the same template.
Bindings
At this point, we have an instance of the controller and a template that uses this controller instance
The next step is to expect to bind the element using the controller’s properties.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | var bindings = {}; // Note: element is the dom element using the controller Array.prototype.slice.call(element.querySelectorAll('[ng-bind]')) .map(function (element) { var boundValue = element.getAttribute('ng-bind'); if(!bindings[boundValue]) { bindings[boundValue] = { boundValue: boundValue, elements: [] } } bindings[boundValue].elements.push(element); }); |
Quite simply, it stores all the constraints of an object (used as a hash).
This variable contains all the properties associated with the current property and all DOM elements bound from this property
Bind controller properties 2-way
After the preliminary work has been done by the Framework, now comes the fun part: double-binding.
It involves binding the controller’s properties to the DOM elements whenever the code updates the value of the properties.
Also, don’t forget to associate DOM elements with controller properties. In this way, when the user changes the input value, it updates the controller’s properties. It then also updates the other DOM elements associated with that property.
Detect updates from code via proxy
As explained above, Vue wraps components in a proxy to interact with changes to properties. Do the same thing by delegating the setter to only properties bound to the controller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // Note: ctrl is the controller instance var proxy = new Proxy(ctrl, { set: function (target, prop, value) { var bind = bindings[prop]; if(bind) { // Update each DOM element bound to the property bind.elements.forEach(function (element) { element.value = value; element.setAttribute('value', value); }); } return Reflect.set(target, prop, value); } }); |
Whenever a property is set, the proxy looks for all the elements associated with the property. It will then update them with new values.
In this example, we will only support constraints from the input element, since their value is set.
Reaction on element events
The last thing to do is listen for user interactions on the interface. DOM elements trigger events when they detect value changes
Listen for those events and update properties that bind with new values
1 2 3 4 5 6 7 8 9 10 11 | Object.keys(bindings).forEach(function (boundValue) { var bind = bindings[boundValue]; // Listen elements event and update proxy property bind.elements.forEach(function (element) { element.addEventListener('input', function (event) { proxy[bind.boundValue] = event.target.value; // Also triggers the proxy setter }); }) }); |
You can watch the full demo here