Two-Ways Binding in Frontend Implementation
Two-ways binding's minimal implementations and simple examples with JavaScript in frontend.
In frontend interaction between components and data state there are some exist and mature thoughts like One-way binding (e.g. React, Svelte) and Two-way binding(e.g. Vue).
Now we will peruse the two-way binding which makes the UI and state automatically update each other.
Here are the graph to differentiate them.
Event Listening + DOM
The earliest two-way binding was done purely manually. For example, for a text input: listen to the DOM's input
/change
event, manually update the data, and then manually modify the DOM.
const data = { message: "Hello" };
const input = document.querySelector("#input");
const display = document.querySelector("#display");
// Data -> View
function updateView() {
input.value = data.message;
display.textContent = data.message;
}
// View -> Data
input.addEventListener("input", e => {
data.message = e.target.value;
updateView();
});
// Initialize
updateView();
This is the ancient implementation method, the most intuitive, compatible with all browsers, but cumbersome and high maintenance cost. When the complexity of frontend pages reaches a certain level, the maintenance cost increases exponentially.
defineProperty
Data Hijacking
To solve the complexity problem of the above maintenance cost, Vue2 proposed a simple method: utilize JavaScript language features, bind getters and setters, to achieve the effect of automatically updating the view and state bidirectionally.
Before Proxy
appeared, the most common way was to use Object.defineProperty
to intercept the object property's getter / setter
, triggering view updates when setting properties.
The general idea is:
- Use
Object.defineProperty
to hijack the data's getter/setter. - Collect dependencies in the getter (who uses this data).
- Notify dependencies to update in the setter (Publish-Subscribe pattern).
const data = {};
let value = "Hello";
Object.defineProperty(data, "message", {
get() {
return value;
},
set(newVal) {
value = newVal;
console.log("View updated:", newVal);
document.querySelector("#input").value = newVal;
}
});
// Data -> View
data.message = "Hi defineProperty";
// View -> Data
document.querySelector("#input").addEventListener("input", e => {
data.message = e.target.value;
});
This method has good compatibility in IE9+. But the disadvantage is: it can only hijack existing properties; adding/deleting properties cannot be listened to.
Dirty Checking
Angular.js took a different approach: continuously polling the consistency of the state and view, and then updating the corresponding state and view. This is also called the Dirty Checking mechanism: regularly checking whether the data and DOM are consistent, and updating if they are not.
For Angular, its core is a mechanism called the $digest
loop: whenever data might change (user input, click events, Ajax callbacks, $timeout
, $http
, etc., at appropriate times), Angular triggers a dirty check cycle.
During the $digest
process, Angular checks all Watchers (each bound data corresponds to a watcher) in turn. If it finds that some data is different from the last value, it calls the corresponding callback to update the DOM.
Because updating one piece of data may trigger changes in other data, $digest will run up to 10 times (to prevent infinite loops).
For demonstration convenience, use setInterval
to temporarily replace polling for dirty checking.
let data = { message: "Hello" };
let oldMessage = data.message;
setInterval(() => {
if (data.message !== oldMessage) {
oldMessage = data.message;
console.log("Update view:", data.message);
document.querySelector("#display").textContent = data.message;
}
// #display is the input component
}, 100);
This approach is simple to implement. But the performance is very poor; when involving large-scale data state updates, obvious lag can be felt.
Therefore, in Angular 2.x and later versions, this "two-way binding" idea was abandoned. After using TypeScript, the design philosophy changed to unidirectional data flow + one-way binding. To achieve two-way binding, it must be implemented manually, and Angular also provides corresponding syntactic sugar.
Proxy
Vue2 implemented reactivity based on Object.defineProperty
, but there were mainly several problems:
- Can only hijack existing properties. You must first define the properties on the object, then use
Object.defineProperty
to add getter/setter. Moreover, operations like adding properties / deleting properties cannot be listened to (Vue2 neededVue.set
,Vue.delete
hacks). - Cannot directly listen to array index changes. Methods like
push/pop/shift/unshift/splice
on arrays need to be rewritten separately. And operations like direct assignment are also not friendly; directly doingarr[0] = newVal
cannot trigger updates. - Requires recursively traversing the object. Need to add getter/setter to every property at every level of the listened object. However, initializing deeply nested objects is very performance-intensive.
After the ES6 standard was released, Vue3 used Proxy
to implement reactivity (replacing Vue2's Object.defineProperty
). Proxy is a relatively new and elegant approach.
Vue3 rewrote the reactivity system using Proxy
, solving the above problems. The getter/setter
of the Proxy method is somewhat different from the getter/setter
set by Object.defineProperty
. Specifically:
- Can intercept the entire object
Proxy
does not need to define getter/setter for each property individually. It can be handled uniformly directly inget/set
. - Supports dynamic properties Adding/deleting properties can be intercepted and listened to. So extra APIs like
Vue.set
are no longer needed. - Native array support Accessing array indices,
length
, or executing methods likepush
will trigger theset
interceptor. Therefore, there's no need to manually rewrite the array prototype for extra array operations. - Supports more operations
Object.defineProperty
can only intercept property read/write. ButProxy
can intercept 13 operations:get
,set
,deleteProperty
, etc. This makes the reactivity system more flexible (e.g.,for...in
traversal can also trigger dependency collection).
Proxy can also achieve better performance. When initializing an object, there's no need to deeply traverse all properties; it only triggers get
when actually accessed, resulting in better performance.
Next, let's use the Proxy mechanism to implement a real DOM two-way binding version that can bind object properties to <input>
elements, using the Proxy mechanism to implement a simple two-way binding mechanism.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Proxy Two-Way Binding Example</title>
</head>
<body>
<input id="input" type="text" />
<p id="display"></p>
<script>
// Data object
const data = { message: 'Hello Proxy' };
// Get DOM elements
const inputEl = document.getElementById('input');
const displayEl = document.getElementById('display');
// Display initial value
inputEl.value = data.message;
displayEl.textContent = data.message;
// Create Proxy, implement Data -> View
const proxyData = new Proxy(data, {
set(target, prop, value) {
target[prop] = value;
// Update view when data changes
if (prop === 'message') {
inputEl.value = value;
displayEl.textContent = value;
console.log(`View updated: ${prop} = ${value}`);
}
return true;
},
get(target, prop) {
return target[prop];
}
});
// View -> Data binding
inputEl.addEventListener('input', (e) => {
proxyData.message = e.target.value; // Automatically update data, trigger Proxy
console.log(`Input changed: ${e.target.value}`);
});
// Test
setTimeout(() => {
proxyData.message = 'Hello Two-Way Binding!';
}, 2000);
</script>
</body>
</html>
In the above code,
- When the data changes, automatically update the
<input>
and<p>
display. - When the input box changes, automatically update the data object.
- The
set
interceptor ofProxy
is responsible for Data -> View, theinput
event is responsible for View -> Data.