In the first article of this series, we saw how a custom build-time compiler can transform a legacy Angular.js template into raw, optimized JavaScript.
To recap, starting from this template:
<!-- simple.html -->
<p>Hello {{ name }}!</p>
Our Go compiler generates the following JavaScript module:
// simple.js
export function template() {
const p_0 = document.createElement("p");
const text_1 = document.createTextNode("");
p_0.append(text_1);
return {
mount(container) {
container.append(p_0);
},
update(change) {
if ("name" in change) {
text_1.data = "Hello " + change.name + "!";
}
}
}
}
This is incredibly clean. By running template(), we get an object with mount and update methods.
Using mount is fully intuitive: we pass a reference to a DOM element, and it injects our empty paragraph (p_0) into it:
import { template } from './simple.js';
const { mount, update } = template();
const container = document.getElementById('view-container');
mount(container);
// The DOM now contains: <p></p> (waiting for data)
However, the paragraph remains empty until we call update with a change object like this:
let changes = {
name: "Mario",
};
update(changes);
// The DOM surgically updates to: <p>Hello Mario!</p>
But who is responsible for tracking changes in our application state, building this changes object, and calling update?
The answer lies in marrying the legacy Angular.js $scope with the modern JavaScript Proxy API.
The Legacy State Pattern
In a traditional Angular.js application, developers mutate the state directly inside a controller by assigning properties to the $scope object:
// simple-controller.js
export function SimpleController($scope) {
$scope.name = "Mario";
}
To bridge the gap between this legacy controller and our new build-time template, we need a way to automatically capture the assignment $scope.name = "Mario" and translate it into a structured update:
let changes = {
name: "Mario"
};
Instead of running a heavy runtime digest cycle to dirty-check the entire scope, we can intercept these mutations at the exact moment they happen. This is where Proxies shine.
How the JavaScript Proxy API Saves the Day
The Proxy object allows us to wrap a target object and intercept fundamental operations, such as property lookups, assignments, and function invocations.
By wrapping our $scope in a Proxy before passing it to the controller, we can execute custom code whenever a property is set.
Let's look at how we can implement a basic set trap:
import { SimpleController } from './simple-controller.js';
// Define a handler with a "set" trap
const handler = {
set(target, prop, value) {
console.log(`Property "${prop}" changed to: ${value}`);
// Actually set the value on the target object
target[prop] = value;
// The set trap must return true in strict mode
return true;
}
};
// Wrap an empty object with our Proxy handler
const $scope = new Proxy({}, handler);
// Run the legacy controller with our reactive scope
SimpleController($scope);
// Console logs: Property "name" changed to: Mario
Every time the controller executes $scope.name = "Mario", our Proxy intercepts the assignment. We now have a lightweight, non-invasive mechanism to capture state mutations in real time.
Putting It All Together: The Runtime Connection
Now we can connect our Proxy-based $scope directly to the update method generated by our compiler.
Here is the complete runtime implementation:
import { SimpleController } from './simple-controller.js';
import { template } from './simple.js';
// 1. Initialize the compiled template and mount it
const { mount, update } = template();
const container = document.getElementById('view-container');
mount(container);
// 2. Create the reactive $scope using a Proxy
const $scope = new Proxy({}, {
set(target, prop, value) {
// Intercept mutation, build the change object, and trigger the DOM update
update({ [prop]: value });
// Propagate the change to the underlying object using Reflect
return Reflect.set(target, prop, value);
}
});
// 3. Execute the controller to trigger the initial render
SimpleController($scope);
The result:
The controller runs and executes
$scope.name = "Mario".The Proxy intercepts the write and immediately triggers
update({ name: "Mario" }).The compiled
updatefunction surgically targets theTextNodeand updates the text to"Hello Mario!".Zero dirty-checking, zero Virtual DOM, zero external dependencies.
What’s Next?
While this reactive loop is extremely elegant, real-world enterprise applications are rarely this simple.
What happens when a controller updates multiple properties in a row? In our current basic implementation, changing three properties sequentially would trigger three immediate, synchronous DOM repaints. To prevent layout thrashing, we need to implement a batching mechanism to queue updates and flush them once per frame.
Furthermore, how do we handle nested objects, arrays, and dependency tracking (Signals)?
In the next part of this series, we will explore how we scaled this runtime architecture to handle production-grade state management. Stay tuned!
Thanks for reading! I’m a Frontend Architect passionate about compilers, reactivity, and performance. Let's connect on LinkedIn to stay updated with the next parts of this journey.
Top comments (0)