Let’s make the worst Vue.js in the world
Reactivity
Like React.js, Vue is reactive, meaning that all changes to the application state are automatically reflected in the DOM. But unlike React, Vue keeps track of dependencies at render time and only updates related parts without any “comparisons”.
The key to Vue.js reactivity is the method Object.defineProperty
… It allows you to specify a custom getter / setter method on an object field and intercept every access to it:
const obj = {a: 1};
Object.defineProperty(obj, 'a', {
get() { return 42; },
set(val) { console.log('you want to set "a" to', val); }
});
console.log(obj.a); // prints '42'
obj.a = 100; // prints 'you want to set "a" to 100'
With this, we can determine when a particular property is being accessed, or when it changes, and then re-evaluate all dependent expressions after the property has changed.
Expressions
Vue.js allows you to bind a JavaScript expression to a DOM node attribute using a directive. For instance, <div v-text="s.toUpperCase()"></div>
will set the text inside the div to the value of a variable s
in uppercase.
The simplest approach to evaluating strings like s.toUpperCase()
, use eval()
… Although eval was never considered a safe solution, we can try to make it a little better by wrapping it in a function and passing in a custom global context:
const call = (expr, ctx) =>
new Function(`with(this){${`return ${expr}`}}`).bind(ctx)();
call('2+3', null); // returns 5
call('a+1', {a:42}); // returns 43
call('s.toUpperCase()', {s:'hello'}); // returns "HELLO"
It’s slightly safer than native eval
and it is enough for the simple framework we are building.
Proxy
We can now use Object.defineProperty
to wrap each property of the data object; can be used call()
to evaluate arbitrary expressions and to tell which properties the expression accessed directly or indirectly. We also need to be able to determine when the expression should be reevaluated because one of its variables has changed:
const data = {a: 1, b: 2, c: 3, d: 'foo'}; // Data model
const vars = {}; // List of variables used by expression
// Wrap data fields into a proxy that monitors all access
for (const name in data) {
let prop = data[name];
Object.defineProperty(data, name, {
get() {
vars[name] = true; // variable has been accessed
return prop;
},
set(val) {
prop = val;
if (vars[name]) {
console.log('Re-evaluate:', name, 'changed');
}
}
});
}
// Call our expression
call('(a+c)*2', data);
console.log(vars); // {"a": true, "c": true} -- these two variables have been accessed
data.a = 5; // Prints "Re-evaluate: a changed"
data.b = 7; // Prints nothing, this variable does not affect the expression
data.c = 11; // Prints "Re-evaluate: c changed"
data.d = 13; // Prints nothing.
Directives
We can now evaluate arbitrary expressions and keep track of which expressions to evaluate when one particular data variable changes. All that remains is to assign expressions to certain properties of the DOM node and actually change them when the data changes.
As with Vue.js, we will be using special attributes like q-on:click
to bind event handlers, q-text
for binding textContent, q-bind:style
for CSS style binding and so on. I use the prefix “q-” here because “q” is similar to “vue”.
Here is a partial list of possible supported directives:
const directives = {
// Bind innerText to an expression value
text: (el, _, val, ctx) => (el.innerText = call(val, ctx)),
// Bind event listener
on: (el, name, val, ctx) => (el[`on${name}`] = () => call(val, ctx)),
// Bind node attribute to an expression value
bind: (el, name, value, ctx) => el.setAttribute(name, call(value, ctx)),
};
Each directive is a function that takes a DOM node, an optional parameter name for cases like q-on:click
(the name will be “click”). It also requires an expression string (value
) and a data object to use as the context of the expression.
Now that we have all the building blocks, it’s time to glue everything together!
Final result
const call = .... // Our "safe" expression evaluator
const directives = .... // Our supported directives
// Currently evaluated directive, proxy uses it as a dependency
// of the individual variables accessed during directive evaluation
let $dep;
// A function to iterate over DOM node and its child nodes, scanning all
// attributes and binding them as directives if needed
const walk = (node, q) => {
// Iterate node attributes
for (const {name, value} of node.attributes) {
if (name.startsWith('q-')) {
const [directive, event] = name.substring(2).split(':');
const d = directives[directive];
// Set $dep to re-evaluate this directive
$dep = () => d(node, event, value, q);
// Evaluate directive for the first time
$dep();
// And clear $dep after we are done
$dep = undefined;
}
}
// Walk through child nodes
for (const child of node.children) {
walk(child, q);
}
};
// Proxy uses Object.defineProperty to intercept access to
// all `q` data object properties.
const proxy = q => {
const deps = {}; // Dependent directives of the given data object
for (const name in q) {
deps[name] = []; // Dependent directives of the given property
let prop = q[name];
Object.defineProperty(q, name, {
get() {
if ($dep) {
// Property has been accessed.
// Add current directive to the dependency list.
deps[name].push($dep);
}
return prop;
},
set(value) { prop = value; },
});
}
return q;
};
// Main entry point: apply data object "q" to the DOM tree at root "el".
const Q = (el, q) => walk(el, proxy(q));
A reactive, Vue.js-like framework at its finest. How useful is it? Here’s an example:
<div id="counter">
<button q-on:click="clicks++">Click me</button>
<button q-on:click="clicks=0">Reset</button>
<p q-text="`Clicked ${clicks} times`"></p>
</div>
Q(counter, {clicks: 0});
Pressing one button increments the counter and automatically refreshes the content <p>
… Clicking another sets the counter to zero and also updates the text.
As you can see, Vue.js looks like magic at first glance, but inside it is very simple, and the basic functionality can be implemented in just a few lines of code.
Further steps
If you’re interested in learning more about Vue.js, try implementing “q-if” to toggle the visibility of elements based on an expression, or “q-each” to bind lists of duplicate children (this would be a good exercise).
The full source code for the Q nanoframe is at Github… Feel free to donate if you spot a problem or want to suggest an improvement!
In conclusion, I must mention that Object.defineProperty
was used in Vue 2 and the creators of Vue 3 switched to another mechanism provided by ES6, namely Proxy
and Reflect
… Proxy allows you to pass a handler to intercept access to object properties, as in our example, while Reflect allows you to access object properties from inside the proxy and save this
the object intact (unlike our example with defineProperty).
I leave both Proxy / Reflect as an exercise for the reader, so whoever makes a pull request to use them correctly in Q – I’ll be happy to combine that. Good luck!
Hope you enjoyed the article. You can follow the news and share offers in Github, Twitter or subscribe through rss…