Let's make the worst VueJS ever!

Some time ago I wrote a similar post about React, in which with a few lines of code we have built a tiny ReactJS clone from scratch. But React is not the only choice in the modern frontend world, and VueJS is constantly gaining its popularity, so why don’t we look deeper into how it works and maybe create a silly educational VueJS-like framework.

Reactivity

Like ReactJS, Vue is reactive, meaning that all the changes in the application state are automatically reflected in DOM. But unlike React, Vue keeps track of the dependencies during its render and only updates the related parts without any “diffing”.

The key to VueJS reactivity is Object.defineProperty method. It allows to specify a custom getter/setter for 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 the help of this, we can now detect when a certain property is accessed or modified and re-evaluate all dependent expressions once the property has changed.

Expressions

VueJS allows to bind JavaScript expression to a DOM node attribute with a directive. For example, <div v-text="s.toUpperCase()"></div> would set div’s text to the upper-case contents of the variable s.

The simplest approach to evaluate strings, such as s.toUpperCase() is to use eval(). Although eval is never considered a safe solution, we can try making it a little bit safer by wrapping it into a function and passing a special global context to it:

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"

This is a little bit more secure than raw eval and is good enough to a simple framework like the one we are building.

Proxies

Now we can use Object.defineProperty to wrap every data object’s property, use call() to evaluate arbitrary expressions, and tell what properties have been directly or indirectly accessed by the expression. We also should be able to tell when the expression has to be re-evaluated because one of its variables have 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

Now we can evaluate arbitrary expressions and keep track of which expressions to evaluate when one particular data variable changes. The only thing left is to assign expressions to certain DOM node properties and actually change them when data changes.

Like in VueJS, we will be using special attributes, such as q-on:click to bind event listeners, q-text to bind textContent, q-bind:style to bind CSS style and so on. I’m using “q-” prefix here because “q” sounds similar to “vue”.

Here’s an incomplete list of possibly 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 the DOM node, optional parameter name for cases like q-on:click (name would be “click”). It also takes an expression string (value) and a data object to use as an expression context.

Now we have all building blocks ready, time to glue it all together!

End 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));

This is the whole reactive Vue-like framework in all its glory. How useful is it? Well, 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});

Clicking on one button increments the counter and updates <p> contents automatically. Clicking on another one sets the count to zero and also updates the text.

As you see, VueJS might appear magical at a first glance, but inside it turns our to be very simple and the core functionality can be implemented in just a few lines of code.

Further steps

If you are curious about VueJS - try implementing “q-if” to toggle element visibility based on the expression, or “q-each” to bind lists of repeating child elements (this might be an interesting exercise).

The complete sources of the Q nano-framework are on Github: https://github.com/zserge/q. Feel free to contribute if you spot an issue or have an improvement to suggest!

As a closing note, I have to mention that Object.defineProperty has been used by Vue 2, while in Vue 3 they have switched to a different mechanism, provided by ES6 - namely, Proxy and Reflect. Proxy allows to pass a handler to intercept object properties access, much like we did in our example, while Reflect allows to access object properties from inside the Proxy and keep this object intact (unlike in our example with defineProperty).

I leave both Proxy/Reflect as an exercise to the reader, so whoever makes the PR to use them correctly in Q - I’ll be happy to merge it. Have fun!

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Feb 07, 2021

See also: Let's make the worst React ever! and more.