Another Two-Way Data Binding Article
I recently read Robert Yarborough's article (How and why I built 2-Way Data Binding Library) and while his library was super lightweight, easy to implement, and would certainly work for many simple use cases I thought it was lacking in some areas. So with this in mind, I set about creating something a bit more robust and scalable.
Skip to my approach
View the completed codepen
Concerns with the previous approach
EventListener
explosion
In his article, Robert takes a very direct approach to syncronizing input data by adding an onKeyup
function to every element that has a specific CSS class
that dictates the data in scope that it binds to. While this would work for small applications, as the number of elements syncing to data increases you would see more sluggish performance. Here is a jsPerf illustrating how the browser handles binding many event listeners to many elements.
keyup
Event
I think because utilizing change
or input
would create an infinite loop when an input element was updated, Robert used keyup
to trigger $scope
udpates. While this avoided the obvious pitfall of breaking the entire application, it lacks some common functionality that I believe many users would expect such as pasting using the right click menu. Because I was already resolved to not create the an EventListener
for each element, this was also an issue that would be easily sidestepped.
Empty Initialization
I also found that due to the keyup
event and object setter being the only triggers to syncronize the data, if you prepopulate the value of an input element it will not update until a key is pressed in that input. And if you type into one of the other inputs that shares that data source, or you change the $scope
value through javascript, you will overwrite the initial value entirely.
Predefined data keys
Although defining the keys of the data you plan to syncronize is all well and good, I concluded it required a bit too much boilerplate for my liking. Knowing my own habits of forgetting to update a string somewhere and spending way too long trying to figure out why my code doesn't work, only to have a really disappointing "Ah hah" moment in the end, I decided this was a pattern that would eventually drive me insanse and added dynamic data keys as a requirement for my version as well.
CSS Class based binding
As mentioned above a CSS class
based selector was used as a means of identifying which elements should be syncronized to the $scope
. This is a small but important oversight. Utilizing CSS classes in this way leaves you exposed to potential style inheritance issues. If you accidentally use the class to style an element it could affect the entire application. Or if you style that class intentionally and later decide to apply the data to a different element that you don't want to inherit those styles, you'd be asking for serious regression issues going and adding a new class to all of your existing data bound elements. Anyway, this was a simple thing to resolve, so I simply took a different approach.
My Approach
Finally, the steak and potatoes! Given the issues I listed above, I ended up with this list of features as requirements:
- ES2015+ (Just because it's almost 2018)
- No CSS class based binding
- Limit number of
EventListeners
- Handle all forms of data changing, not just from
keyup
or setting through code - Dynamic data keys
- Automatic synchronization with default values
No CSS class based binding
Since this is the root of quite a few of the other issues, and pretty much the determines how the rest of the library functions, let's start here. Instead of selecting the elements to bind to from their CSS class, we're going to use a data attribute called data-scope
. If you're unfamiliar with HTML data attributes I suggest reading up on them, since they have a whole featureset that make them very easy to work with in javascript. Ok, to our index.html
file is going to get some input elements.
...
<body>
<label>Name: </label>
<input class="input-1" data-scope="name" type="text" />
<input class="input-2" data-scope="name" type="text" />
<hr />
<label>Age: </label>
<input class="input-3" data-scope="age" type="number" />
<input class="input-4" data-scope="age" type="number" />
</body>
Let's go ahead and style all of these inputs differenly too, just to demonstrate the flexibility of moving away from the CSS class based binding.
.input-1 {
background-color: #f00
}
.input-2 {
color: #fff;
background-color: #00f
}
.input-3 {
background-color: #0f0
}
.input-4 {
background-color: #ff0
}
Now we should have something that looks like this.
JS Disclaimer
I should take a quick second to mention that the javascript in this project will be using relatively new features and may not work in older browsers, but I'm pretty sure IE11+ is a safe bet out of the box, provided that you're transpiling with babel
. If you're unfamiliar with babel
or ES2015+
in general, unfortunately that's out of the scope of this post, but feel free to leave a comment and maybe I'll double back on this project and rewrite it in ES5.
EventListeners
Limit number of Now that we have some inputs to orchestrate, let's start writing some code to handle the data binding. My plan for limiting the EventListener
count is to keep track of what element has focus on the page, and only have event listeners on that element. Then, when the element loses focus, each EventListener
should be removed. To do this we need to create a window
listener on the focus
event that will capture all focus
events on the page.
let target;
let $scope = {};
document.addEventListener('DOMContentLoaded', (event) => {
window.addEventListener('focus', handleFocus, true);
});
function handleFocus(evt) {
if (target) {
if (target === evt.target) {
// Don't reset the listeners if the event target is the current target
return;
}
target.removeEventListener('input', handleUpdate);
}
target = evt.target;
target.addEventListener('input', handleUpdate)
}
function handleUpdate(evt) {
// We're going to handle updating $scope here.
}
In the above code, every time a new element gains focus on the page, any previous target
element has it's listener removed then the event target becomes the target
and an input
listener is added.
keyup
or setting through code
Handle all forms of data changing, not just from It was at this point that I decided it best to start to identify the different structures I would be handling in the application and create some classes to help organize the logic. I came to the conclusion that I would require three classes:
- Scope: The main class that contains all the data and provides a proxy for all of the ScopeItem instances
- ScopeItem: This class would handle each individual data value and keep track of the elements that need to be updated when it changes.
- Target: A class wrapper for the
focus
management we did above.
Though Scope
will be the main class that we ultimataly expose to whatever application is using this library, let's start with the ScopeItem
since Scope
is a glofified ScopeItem
factory.
Scope Item
export default class ScopeItem {
constructor(value = null) {
// Creates an empty array of observers and sets _value
this.observers = [];
this._value = value;
return this;
}
// Getter to return the private `_value` property.
get value() {
return this._value;
}
// Setter that calls updateObservers
set value(value) {
this.updateObservers(value);
this._value = value;
}
// Iterates over all observers and calls syncContent for each
updateObservers = (value) => {
this.observers.forEach(this.syncContent(value));
}
// Synchronizes value from ScopeItem to node
syncContent = (value) => {
return (elem) => {
if (elem.nodeName === 'INPUT') {
const type = elem.getAttribute('type');
if (type === 'checkbox' || type === 'radio') {
return elem.checked = value;
}
return elem.value = value;
}
return elem.innerHTML = value;
};
};
// Inserts a new observer element and syncs it's content
addObserver = (elem) => {
this.syncContent(this._value)(elem);
this.observers.push(elem);
}
// Removes an observer element
removeObserver = (elem) => {
const index = this.observers.indexOf(elem);
if (index > -1) {
this.observers.pop(index);
}
}
}
Let's disect this method by method, shall we.
constructor(value = null) {
// Creates an empty array of observers and sets _value
this.observers = [];
this._value = value;
return this;
}
The constructor
for ScopeItem
begins by creating an empty array for the observers
propery. This will be used to keep track of the elements that need to be updated when the value is updated. Next it takes the value
argument, which defaults to null
if not supplied, and assigns it to the private _value
property.
// Getter to return the private `_value` property.
get value() {
return this._value;
}
// Setter that calls updateObservers
set value(value) {
this.updateObservers(value);
this._value = value;
}
The getter
for value
simply returns the _value
property. The setter
, on the other hand, calls the updateObservers
method with the new value before updating the _value
property.
// Iterates over all observers and calls syncContent for each
updateObservers = (value) => {
this.observers.forEach(this.syncContent(value));
}
// Synchronizes value from ScopeItem to node
syncContent = (value) => {
return (elem) => {
if (elem.nodeName === 'INPUT') {
const type = elem.getAttribute('type');
if (type === 'checkbox' || type === 'radio') {
return elem.checked = value;
}
return elem.value = value;
}
return elem.innerHTML = value;
};
}
Finally we end up at the updateObservers
method that iterates over each observer and performs the syncContent
method which updates either their value
, checked
or innerHTML
property depending on their nodeName
.
So with our ScopeItem
class complete we would now update every registered obsverer
element any time a ScopeItem
instance is updated. Let's now manage getting those observers added in the first place.
Dynamic data keys
Handling the creation of dynamic data keys is actually a lot simpler than it sounds. The Proxy
object allows the creation of catchall setter
and getter
methods for any property of the object being proxied. This allows us to create a new ScopeItem
every time an element has a new value for data-scope
.
This could also be accomplised using Object.defineProperty
every time an undefined
key is encountered, but Proxy
is more terse, so we'll be using that.
Scope
import ScopeItem from "./ScopeItem";
// Wrapper class for creating the Scope Proxy
export default class Scope {
constructor() {
// Creates proxy
this.$scope = {};
this.proxy = this.createProxy();
// Get the list of elements that utilize `data-scope`
const scopedInputs = document.querySelectorAll("[data-scope]");
// Subscribe all `scopedInputs` to the scope instance
scopedInputs.forEach(this.subscribeToScope);
// Return the proxy for easy access to values in code
return this.proxy;
}
// Static function to normalize element values
static parseElementValue = (elem) => {
if (elem.nodeName === "INPUT") {
const type = elem.getAttribute("type");
if (type === "checkbox" || type === "radio") {
return elem.checked;
}
return elem.value;
}
return elem.innerHTML;
};
// Creates a Proxy that instantiates a new ScopeItem for every key accessed and only allows the value to be updated
createProxy = () => {
return new Proxy(this.$scope, {
get(target, key) {
if (!target[key]) {
target[key] = new ScopeItem();
}
return target[key].value;
},
set(target, key, value) {
if (!target[key]) {
target[key] = new ScopeItem();
}
target[key].value = value;
return true;
}
});
};
// Adds element to scope observer array to be updated on changes
subscribeToScope = (elem) => {
const scopeKey = elem.dataset.scope;
if (!this.$scope[scopeKey]) {
this.$scope[scopeKey] = new ScopeItem(Scope.parseElementValue(elem));
}
this.$scope[scopeKey].addObserver(elem);
};
}
And again we'll break this down method by method.
constructor() {
// Creates proxy
this.$scope = {};
this.proxy = this.createProxy();
// Get the list of elements that utilize `data-scope`
const scopedInputs = document.querySelectorAll("[data-scope]");
// Subscribe all `scopedInputs` to the scope instance
scopedInputs.forEach(this.subscribeToScope);
// Return the proxy for easy access to values in code
return this.proxy;
}
The constructor
does quite a bit in the Scope
class. It begins by creating an empty object as the $scope
property and also populates the proxy
property using the createProxy
method.
Next, it stores all elements from the dom that contain a data-scope
attribute in a scopedInputs
constant that is then iterated over, performing the subscribeToScope
method on each element.
Finally, the proxy
property is actually returned as this is a more convenient way for external code to interface with the underlying ScopeItem
instances.
// Static function to normalize element values
static parseElementValue = (elem) => {
if (elem.nodeName === "INPUT") {
const type = elem.getAttribute("type");
if (type === "checkbox" || type === "radio") {
return elem.checked;
}
return elem.value;
}
return elem.innerHTML;
};
The next method is a static
method called parseElementValue
. This is necessary in order to properly set the ScopeItem
values based on the type of elements the value is being set from.
// Creates a Proxy that instantiates a new ScopeItem for every key accessed and only allows the value to be updated
createProxy = () => {
return new Proxy(this.$scope, {
get(target, key) {
if (!target[key]) {
target[key] = new ScopeItem();
}
return target[key].value;
},
set(target, key, value) {
if (!target[key]) {
target[key] = new ScopeItem();
}
target[key].value = value;
return true;
}
});
};
The createProxy
method does as stated at the beginning of this section. It creates dynamic getters
and setters
for all properties of the $scope
property. This allows us to automatically create a ScopeItem
for any key that is accessed either by reading or writing.
Further it allows us to hide the underlying ScopeItem
properties that are of no interest to someone utilizing the library. Accessing any key in the Scope
instance will return only the value
property from the corresponding ScopeItem
instance.
// Adds element to scope observer array to be updated on changes
subscribeToScope = (elem) => {
const scopeKey = elem.dataset.scope;
if (!this.$scope[scopeKey]) {
this.$scope[scopeKey] = new ScopeItem(Scope.parseElementValue(elem));
}
this.$scope[scopeKey].addObserver(elem);
};
Utilized in the constructor
the subscribeToScope
method takes an element containing a data-scope
attribute and adds it to the corresponding ScopeItem
observers
property via the addObserver
method.
This is also the only method that directly accesses the $scope
property as it needs access to the underlying ScopeItem
addObserver
method.
Inside of this method another one of our requirements (Automatic synchronization with default values) is met as well. The passing of the element value to a new ScopeItem
if $scope
doesn't contain the expected key will in turn set that value for any following elements that are added as observers to that ScopeItem
.
Target (revisited)
Now everything has come full circle as we create the Target
class to handle all the EventListener
management. Because the Target
class will require the ability to update the State
, we need to pass a reference, as well as create an instance inside the State
class. So let's modify our imports and constructor
in State
import ScopeItem from "./ScopeItem";
import Target from "./Target"; // Add this line
// Wrapper class for creating the Scope Proxy
export default class Scope {
constructor() {
// Creates proxy and Target reference
this.$scope = {};
this.proxy = this.createProxy();
this.target = new Target(this.proxy); // Add this line
...
}
...
}
And here is the full Target
class.
import Scope from "./Scope";
// Class for managing target element and minimizing active eventListeners;
export default class Target {
// Takes a scope instance as an argument to handle updates
constructor($scope) {
this.$scope = $scope;
// Event listener object to remove listeners later in the lifecycle
this.eventListeners = {
input: this.handleUpdate,
change: this.handleUpdate
};
// Sets the initial element to the active document element
this.element = document.activeElement;
// Listens for all focus events on the window object
window.addEventListener("focus", this.handleFocus, true);
}
// Getter to return the private `_element` property.
get element() {
this._element;
}
// Setter that performs event listener cleanup and setup whenever element changes
set element(element) {
this.removeEventListeners(this._element);
this.addEventListeners(element);
return this._element = element;
}
// Removes event listeners from element that is losing focus
removeEventListeners = (element) => {
if (!element) {
return;
}
for (let key in this.eventListeners) {
element.removeEventListener(key, this.eventListeners[key]);
}
};
// Adds event listeners from element that is gaining focus
addEventListeners = (element) => {
if (!element) {
return;
}
for (let key in this.eventListeners) {
element.addEventListener(key, this.eventListeners[key]);
}
};
// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleUpdate = (evt) => {
const scopeKey = evt.target.dataset ? evt.target.dataset.scope : null;
if (scopeKey) {
this.$scope[scopeKey] = Scope.parseElementValue(evt.target);
}
};
// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleFocus = (evt) => {
this.element = evt.target;
};
}
And the final breakdown
constructor($scope) {
this.$scope = $scope;
// Event listener object to remove listeners later in the lifecycle
this.eventListeners = {
input: this.handleUpdate,
change: this.handleUpdate
};
// Sets the initial element to the active document element
this.element = document.activeElement;
// Listens for all focus events on the window object
window.addEventListener("focus", this.handleFocus, true);
}
This should all look vaguely familiar from before. The constructor
first assigns it's own $state
property to the state passed as an argument. Then it defines the eventListeners
property which is handy later on for adding and removing EventListeners
easily. The element
property is set to the current activeElement
of the document. And finally the focus
EventListener
is added to the window
just as before.
// Getter to return the private `_element` property.
get element() {
this._element;
}
// Setter that performs event listener cleanup and setup whenever element changes
set element(element) {
this.removeEventListeners(this._element);
this.addEventListeners(element);
return this._element = element;
}
The element
getter
is another simple wrapper for the _element
property, while it's setter
handles removing and adding event listeners as the element
peoperty is changed.
// Removes event listeners from element that is losing focus
removeEventListeners = (element) => {
if (!element) {
return;
}
for (let key in this.eventListeners) {
element.removeEventListener(key, this.eventListeners[key]);
}
};
// Adds event listeners from element that is gaining focus
addEventListeners = (element) => {
if (!element) {
return;
}
for (let key in this.eventListeners) {
element.addEventListener(key, this.eventListeners[key]);
}
};
Both the addEventListeners
and removeEventListeners
methods perform more or less the same operation. They loop over the keys in the eventListeners
property, and either add or remove the corresponding EventListener
from the element argument.
// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleUpdate = (evt) => {
const scopeKey = evt.target.dataset ? evt.target.dataset.scope : null;
if (scopeKey) {
this.$scope[scopeKey] = Scope.parseElementValue(evt.target);
}
};
The handleUpdate
method is performed on both change
and input
events (change
was added for checkbox and radio button support) and utilizes the Scope.parseElementValue
static method to update the corresponding ScopeItem
that the currently targeted element is bound to.
// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleFocus = (evt) => {
this.element = evt.target;
};
And last but not least, we have the handleFocus
method which still does the same thing it did at the beginning of this post, it assigns the event target element to the element
property triggering all the EventListener
removal and addition.
BONUS ROUND!!!
Just because it really wasn't much more effort after all of that, I added the ability to add and remove DOM elements that utilize data-scope
on the fly. We simply add a MutationObserver
to the Scope
class that watches for changes to the body and either adds an element to a ScopeItem
's observers
property when created, or removes one when deleted. Add the following to the Scope
class.
export default class Scope {
constructor() {
...
this.target = new Target(this.proxy);
this.observeMutations(); // Add this line
...
}
// Manages scope through any DOM manipulation events
observeMutations = () => {
// create an observer instance
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(elem => {
this.handleNodeInserted(elem);
});
mutation.removedNodes.forEach(elem => {
this.handleNodeRemoved(elem);
});
}
});
});
// configuration of the observer:
const config = { childList: true };
// pass in the body as the target node, as well as the observer options
observer.observe(document.querySelector("body"), config);
};
// Called from the MutationObserver when an element is inserted into the DOM
handleNodeInserted = element => {
const scopeKey = element.dataset ? element.dataset.scope : null;
if (scopeKey) {
this.subscribeToScope(element);
}
};
// Called from the MutationObserver when an element is removed from the DOM
handleNodeRemoved = element => {
const scopeKey = element.dataset ? element.dataset.scope : null;
if (scopeKey) {
this.$scope[scopeKey].removeObserver(element);
}
};
...
}