Let’s make the worst Vue.js in the world

Some time ago I posted a similar article about Reactwhere, with a couple of lines of code, we’ve created a tiny clone of React.js from scratch. But React is far from the only tool in the modern front-end world, Vue.js is rapidly gaining popularity. Let’s take a look at how this framework works and create a primitive clone similar to Vue.js for educational purposes.

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 evaland it is enough for the simple framework we are building.

Proxy

We can now use Object.definePropertyto 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

Similar Posts

Leave a Reply