Using Proxy and Virtual DOM to Build Your Own Framework


JavaScript Proxy

Proxy is a feature newly introduced in ES2015. Although IE11 does not support Proxy, other modern browser are compatible with it. While Proxy cannot be transpiled using Babel, Google offers a polyfill so that it can be compatible with IE11.

Source : https://babeljs.io/learn-es2015/

image

Refer to ES6 Features – 10 Uses of Proxy for examples of Proxy usages.

Using Proxy to Build Responsive Model Class

handler.set() hooks onto the object at the point of transmission and can be manipulated to perform additional tasks like prevalidating values. In the example, the handler.set() is used to detect changes made in the model and call the registered callback function to alert that the model has been changed. So far, it operates similarly to a stage designed to detect model changes in order to represent one-way binding.

export default class Model {
  constructor(callback) {
    const proxy = new Proxy(this, {
      get(target, property) {
        return target[property];
      },
      set(target, property, value) {
        const oldValue = target[property];
        target[property] = value;

        // Notify model changes
        if (callback) {
          callback(property, oldValue, value);
        }

        // Return true if successful. In strict mode, returning false will throw a TypeError exception.
        return true;
      }
    });

    return proxy;
  }
}

The Model class, mentioned above, is a simple class without a single property. Model class constructor does not return itself, but returns a Proxy instance it created. The Proxy can now detect every change made to the Model with the help of the handler.set() function.

It is also possible to inherit the Model class that created the Proxy to create new model classes, or raise errors when properties that were not predetermined are added or changed.

const predefinedProps = ["name", "age"];

const handler = {
  set(target, property, value) {
    if (!predefinedProps.includes(property)) {
      throw new TypeError(`${property} cannot be set`);
    }

    if (property === "age" && !Number.isInteger(value)) {
      throw new TypeError(`${property} is not an integer`);
    }

    target[property] = value;
    return true;
  }
};

Creating a View to Render the Model

Let’s create a simple clock by using the model value. Both ES6 template literal and JSX can be used for rendering. Here, for the sake of simplicity, let’s use template literal. The view and the model operate as follows.

  • When a timer is called, the model value is changed.
  • The value change is hooked to Proxy’s handler.set().
  • Call the callback function to alert the view.
  • Call the render() function to update the view.

Now, let’s create the view.

Creating a View that Contains the Model

The view serves the purpose of taking the container element as a parameter to render the HTML in the container element. The view constructor creates the previously mentioned responsive Model, and it registers the onChanges() callback function to detect the property value change of the Model. Timer is executed every second to change the value of the model value using the onTick() callback function. The onChanges() callback function uses the render() function to renew the view once the change has been detected.

import Model from "./model";

export default class View {
  constructor(container) {
    this.container = container;
    this.model = new Model(this.onChanges.bind(this));

    this.timer = setInterval(this.onTick.bind(this), 1000);
  }

  onChanges(property, oldValue, newValue) {
    this.render();
  }

  render() {
    const { hours, minutes, seconds } = this.model;
    const html = `
               <span>${hours}</span>:
               <span>${minutes}</span>:
               <span>${seconds}</span>`;

    this.container.innerHTML = html;

    console.log("render()");
  }

  onTick() {
    const now = new Date();

    this.model.hours = now.getHours();
    this.model.minutes = now.getMinutes();
    this.model.seconds = now.getSeconds();
  }
}

Upon inspecting the onTick() function, it can be seen that it only manipulates the property in this.model. Then, when does the rendering happen? The Proxy magic happens at the moment the property is changed. With the help of the Proxy, rendering happens automatically each time a change has been made. Let’s summarize the concept.

  • model value changes at View.onTick()
  • hooking at value change from Proxy handler.set() defined inside of the Model class
  • change event is alerted by calling a registered callback function
  • View.render() is called from the View.onChanges() callback function, and renders the view

On the browser console, it can be seen that the render() function is called three times every second. This happens because the model value has been changed three times.

Decreasing the Rendering Frequency

Rendering every time there is a change in the model properties results in excessive layout rendering by the browser, and is not good for performance. Let’s make it so that the changes are collected and rendered at once. The requestAnimationFrame allows the model change that happened in a frame to be applied in the next frame.

export default class View {
    constructor(container) {
...

        this.renderFunc = this.render.bind(this);
        this.requestRender = 0;
    }

    onChanges(property, oldValue, newValue) {
        if (this.requestRender) {
            cancelAnimationFrame(this.requestRender);
        }

        this.requestRender = requestAnimationFrame(this.renderFunc);
    }

    render() {
...
        this.requestRender = 0;
    }

Using the requestAnimationFrame() prevents the model from rendering every time there is a change in the model value by cancelAnimationFrame() even if the onChanges() is called multiple times. In the console, it can be observed that the rendering happens every second instead of three times every second.

Rendering Only If There Is a Value Change

In the clock example, model changes every second, but hours and minutes are values that do not change with equivalent frequency. However, the Model class designed previously calls the callback functions to every change made. Let’s improve it so that the callback function is called only if there has been change.

export default class Model {
    constructor(callback) {
        const proxy = new Proxy(this, {
            set(target, property, value) {
                const oldValue = target[property];
                target[property] = value;

                // Notify model changes if value is changed.
                if (value !== oldValue && callback) {
                    callback(property, oldValue, value);
                }
...
            }
        });
...
    }
}

This example simply uses the !== operator to compare the primitive values and reference value. The program became much lighter since it calls the callback function only if there has been real change to the model value.

Using the Virtual DOM to Improve Rendering

Previously, this document discussed ways to improve the rendering frequency with respect to the model change. Also, upon inspecting each rendering, the child HTML of the container is replaced every time it renders because the container.innerHTML is substituted when as the new HTML is rendered. In the clock example, hours and minutes do not change very often, and the HTML does not have to be renewed every time. Let’s use the virtual DOM to improve this feature.

On Virtual DOMs

Virtual Dom is a key concept used in React, Vue, and many other frameworks. It is a method that replaces the DOM not every time the view is rendered, but only the parts that need changing upon comparison. In React, this is called Reconciliation, and the author recommends the readers to refer to React's official documentation of Reconciliation.

Let’s use the snabbdom used in virtual DOM library Vue (used with forks) and Cycle.js to improve the rendering.

Installing snabbdom

In snabbdom’s Github examples, there is a section that uses the h() function to create DOM nodes. Also, the patch() function can be used to find DOM and replace it.

var snabbdom = require("snabbdom");
var patch = snabbdom.init([
  // Init patch function with chosen modules
  require("snabbdom/modules/class").default, // makes it easy to toggle classes
  require("snabbdom/modules/props").default, // for setting properties on DOM elements
  require("snabbdom/modules/style").default, // handles styling on elements with support for animations
  require("snabbdom/modules/eventlisteners").default // attaches event listeners
]);
var h = require("snabbdom/h").default; // helper function for creating vnodes
var toVNode = require("snabbdom/tovnode").default;

var newVNode = h("div", { style: { color: "#000" } }, [
  h("h1", "Headline"),
  h("p", "A paragraph")
]);

patch(toVNode(document.querySelector(".container")), newVNode);

Install using npm.

npm install snabbdom

Improving Rendering

Following images presents unimproved version of the container, in which DOM is changed every second.

2018-06-11 15_14_25

Let’s use the virtual DOM to only change the parts that require changing.

Importing the Package

snabbdom supports ES6 modules. Import the package as shown below, and reset the patch function.

import { h, init } from "snabbdom";
import toVNode from "snabbdom/es/tovnode";
import props from "snabbdom/es/modules/props";

const patch = init([props]);

Changing the render() Function

Create a virtual node with the h() function, and use the patch() function to render.

render() {
    const { hours, minutes, seconds } = this.model;
    const newVNode = h('div', {props: { id: "wrapper"}}, [
        h('span', {}, hours), ':',
        h('span', {}, minutes), ':',
        h('span', {}, seconds)
    ]);

    patch(this.container, newVNode);

...
}

In this example, patch() function passes the DOM itself as the first parameter. This is a useful technique to use when changing an already-created DOM or working with Server-Side rendering. Since this example does not apply to either, let’s pass the virtual node as the first parameter. In the next example, toVNode() function will transmute the DOM into a virtual node.

constructor(container) {
...
    this.vnode = null;
}

render() {
...
    if (!this.vnode) {
        this.vnode = toVNode(this.container);
    }
    this.vnode = patch(this.vnode, newVNode);
...
}

Now, when the clock example is executed in the browser, it can be seen, as in the following clip, that the only parts of the DOM that is changed is modified.

2018-06-11 16_29_04

Other Possible Improvements

Although it is possible to create a virtual node using h() function from the snabbdom library, but personally, the author does not this it very efficient and lacking intuitiveness. While this document does not mention it, there is also a helper that changes JSX into a virtual node, so the author recommends its readers to take time to read the following articles.

In Conclusion

The author is the maintainer in charge of TOAST UI Calendar. In dealing with the calendar views and models, the author uses the handlebars for rendering. While the he manipulates the DOM directly to create renders, such made it so that DOM is replaced every time HTML was created using handlebars. To cite some more shortcomings, in services that use Vue, Angular, jQuery, and etc, the DOM loses its value even when trying to export it if the DOM is changed again. If the rendering happens again, the scroll, too, will lose its value. Despite the intention of manipulating only the parts that need changing, unintended DOM rendering causes unfortunate frame loss and performance drops.

The idea discussed in this document is the author’s solution to such issue. By using Proxy, the model changes can be controlled and responses can be made automated without using different frameworks. Also, by using a virtual DOM to only manipulate the parts that need changing, it is possible to dramatically increase the application’s performance.

Later, author plans on applying such ideas into the TOAST UI Calendar, if given time.

The examples used in the article can be found on the author’s Github.

How to Use

git clone https://github.com/dongsik-yoo/my-custom-framework.git
npm install
npm run serve

dongsik Yoo, FE Development Lab2019.03.20Back to list