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

A while ago I published a similar article on React where, with a couple of lines of code, we 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 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 .



All Articles