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 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 example, it will
<div v-text="s.toUpperCase()"></div>
set the text inside the div to an uppercase variable value
s
.
The simplest approach to evaluating strings, such as
s.toUpperCase()
, is to 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 a bit safer than the native one
eval
, and is sufficient for the simple framework we're building.
Proxy
We can now use
Object.defineProperty
to wrap each property of a 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 use special attributes such as
q-on:click
to bind event handlers,
q-text
to bind textContent,
q-bind:style
to bind CSS style, and so on. I use the "q-" prefix 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 such as
q-on:click
(the name will be "click"). It also requires an expression string (
value
) and a data object to be used as the expression context.
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 nanoframework is on Github . Feel free to donate if you spot a problem or want to suggest an improvement!
In conclusion, I should mention that
Object.defineProperty
was used in the Vue 2 Vue 3 and the creators have switched to another facility provided 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 within the proxy and keep the
this
object intact (unlike our example with defineProperty).
I leave both Proxy / Reflect as an exercise for the reader, so whoever pulls requests to properly use them in Q - I'll be happy to combine that. Good luck!
Hope you enjoyed the article. You can follow the news and share suggestions on Github , Twitter, or subscribe via rss .