Start a personal dev blog on your domain for free with Hashnode and grow your readership.
Get Started

Building a Simple Virtual DOM from Scratch

I gave a live-coding talk last week at the Manchester Web Meetup #4. I built a virtual DOM from scratch in less than an hour during the talk. It was the most technically complicated talk that I have ever given by far.

The video of my talk is uploaded here. This post is basically a typed-out version of my talk and aim to clarify extra bits that I haven't had time to mention in the talk. I would recommend watching the video before reading this. It would make things a little bit easier to follow.

Here is the github repo and the codesandbox to the code that I wrote in the talk.

Side Notes

  • This article will prepend all variables with
    • $ - when referring to real doms, e.g. $div, $el, $app
    • v - when referring to virtual doms, e.g. vDiv, vEl, vApp
  • This article will be presented like an actual talk with progressive code adding here and there. Each section would have a codesandbox link showing the progress.
  • This article is very very long. Probably take you more than half an hour to read. Make sure you got enough time before reading. Or consider watching the video first.
  • If you spot any mistakes, please don't hesitate to point them out!

Overview

Background: What is Virtual DOM?

Virtual DOMs usually refer to plain objects representing the actual DOMs.

The Document Object Model (DOM) is a programming interface for HTML documents.

For example, when you do this:

const $app = document.getElementById('app');

You will get the DOM for <div id="app"></div> on the page. This DOM will have some programming interface for you to control it. For example:

$app.innerHTML = 'Hello world';

To make a plain object to represent $app, we can write something like this:

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

Didn't mention in the talk

There is no strict rule of how the virtual DOM should look like. You can call it tagLabel instead of tagName, or props instead of attrs. As soon as it represents the DOM, it is a "virtual DOM".

Virtual DOM will not have any of those programming interface. This is what makes them lightweight comparing to actual DOMs.

However, keep in mind that since DOMs are the fundamental elements of the browser, most browsers must have done some serious optimisation to them. So actual DOMs might not be as slow as many people claim.

Setup

codesandbox.io/s/7wqm7pv476?expanddevtools=1

We start of by creating and going into our project directory.

$ mkdir /tmp/vdommm
$ cd /tmp/vdommm

We will then initiate the git repo, create the .gitignore file with gitignorer and initiate npm.

$ git init
$ gitignore init node
$ npm init -y

Let's do out initial commit.

$ git add -A
$ git commit -am ':tada: initial commit'

Next, install Parcel Bundler the truly zero-configuration bundler. It supports all kinds of file format out of the box. It is always my choice of bundler in live-coding talks.

$ npm install parcel-bundler

(Fun fact: you no longer need to pass --save anymore.)

While this is installing, let's create some files in our project.

src/index.html

<html>
  <head>
    <title>hello world</title>
  </head>
  <body>
    Hello world
    <script src="./main.js"></script>
  </body>
</html>

src/main.js

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

console.log(vApp);

package.json

{
  ...
  "scripts": {
    "dev": "parcel src/index.html", // add this script
  }
  ...
}

Now you can spawn the development server by doing:

$ npm run dev

> vdommm@0.0.1 dev /private/tmp/vdommm

> parcel src/index.html



Server running at http://localhost:1234

Built in 959ms.

Going to localhost:1234 and you should see hello world on the page and the virtual DOM we defined in the console. If you see them, then you are correctly set up!

createElement (tagName, options)

codesandbox.io/s/n9641jyo04?expanddevtools=1

Most virtual DOM implementation will have this function called createElement function, often referred as h. These functions will simply return a "virtual element". So let's implement that.

src/vdom/createElement.js

export default (tagName, opts) => {
  return {
    tagName,
    attrs: opts.attrs,
    children: opts.children,
  };
};

With object destructuring we can write the above like this:

src/vdom/createElement.js

export default (tagName, { attrs, children }) => {
  return {
    tagName,
    attrs,
    children,
  };
};

We should also allow creating elements without any options, so let's put some default values for out options.

src/vdom/createElement.js

export default (tagName, { attrs = {}, children = [] } = {}) => {
  return {
    tagName,
    attrs,
    children,
  };
};

Recall the virtual DOM that we created before:

src/main.js

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

console.log(vApp);

It now can be written as:

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
});

console.log(vApp);

Go back to the browser and you should see the same virtual dom as we defined previously. Let's add an image under the div sourcing from giphy:

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

console.log(vApp);

Go back to the browser and you should see the updated virtual DOM.

Didn't mention in the talk

Object literals (e.g. { a: 3 }) automatically inherit from Object. This means that the object created by object literals will have methods defined in the Object.prototype like hasOwnProperty, toString, etc.

We could make our virtual DOM a little bit "purer" by using Object.create(null). This will create a truly plain object that doesn't inherit from Object but null instead.

src/vdom/createElement.js

export default (tagName, { attrs, children }) => {
  const vElem = Object.create(null);

  Object.assign(vElem, {
    tagName,
    attrs,
    children,
  });

  return vElem;
};

render (vNode)

codesandbox.io/s/pp9wnl5nj0?expanddevtools=1

Rendering virtual elements

Now we got a function that generates virtual DOM for us. Next we need a way to translate our virtual DOM to real DOM. Let's define render (vNode) which will take in a virtual node and return the corresponding DOM.

src/vdom/render.js

const render = (vNode) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(vNode.tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(vNode.attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of vNode.children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;

The above code should be quite self-explanatory. I am more than happy to explain more tho if there is any request for it.


ElementNode and TextNode

In real DOM, there are 8 types of nodes. In this article, we will only look at two types:

  1. ElementNode, such as <div> and <img>
  2. TextNode, plain texts

Our virtual element structure, { tagName, attrs, children }, only represents the ElementNode in the DOM. So we need some representation for the TextNode as well. We will simply use String to represent TextNode.

To demonstrate this, let's add some text to our current virtual DOM.

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world', // represents TextNode
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),  // represents ElementNode
  ],
}); // represents ElementNode

console.log(vApp);

Extending render to support TextNode

As I mentioned, we are considering two types of nodes. The current render (vNode) only only renders ElementNode. So let's extend render so that it supports rendering of TextNode too.

We will first rename our existing function renderElem as it is what it does. I will also add object destructuring to make the code looks nicer.

src/vdom/render.js

const renderElem = ({ tagName, attrs, children}) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;

Let's redefine render (vNode). We just need to check if vNode is a String. If it is then we can use document.createTextNode(string) to render the textNode. Otherwise, just call renderElem(vNode).

src/vdom/render.js

const renderElem = ({ tagName, attrs, children}) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

const render = (vNode) => {
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }

  // we assume everything else to be a virtual element
  return renderElem(vNode);
};

export default render;

Now our render (vNode) function is capable of rendering two types of virtual nodes:

  1. Virtual Elements - created with our createElement function
  2. Virtual Texts - represented by strings

Render our vApp!

Now let's try to render our vApp and console.log it!

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world',
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

const $app = render(vApp);
console.log($app);

Go to the browser and you would see the console showing the DOM for:

<div id="app">
  Hello world
  <img src="https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif">
</div>

mount ($node, $target)

codesandbox.io/s/vjpk91op47

We are now able to create our virtual DOM and render it to real DOM. Next we would need to put our real DOM on the page.

Let's first create a mounting point for our app. I will replace the Hello world on the src/index.html with <div id="app"></div>.

src/index.html

<html>
  <head>
    <title>hello world</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./main.js"></script>
  </body>
</html>

What we want to do now is to replace this empty div with our rendered $app. This is super easy to do if we ignore Internet Explorer and Safari. We can just use ChildNode.replaceWith.

Let's define mount ($node, $target). This function will simply replace $target with $node and return $node.

src/vdom/mount.js

export default ($node, $target) => {
  $target.replaceWith($node);
  return $node;
};

Now in our main.js simply mount our $app to the empty div.

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world',
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

const $app = render(vApp);
mount($app, document.getElementById('app'));

Our app will show on the page now and we should see a cat on the page.

Let's make our app more interesting

codesandbox.io/s/ox02294zo5

Now let's make our app more interesting. We will wrap our vApp in a function called createVApp. It will then take in a count which then the vApp will use it.

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
mount($app, document.getElementById('app'));

Then, we will setInterval to increment the count every second and create, render and mount our app again on the page.

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  count++;
  $rootEl = mount(render(createVApp(count)), $rootEl);
}, 1000);

Note that I used $rootEl to keep track of the root element. So that mount knows where to mount our new app.

If we go back to the browser now, we should see the count increment every second by 1 and works perfectly!

We now gain the power to declaratively create our application. The application is rendered predictably and is very very easy to reason about. If you know how things are done in the JQuery way, you will appreciate how much cleaner this approach is.

However, there are a couple of problems with re-rendering the whole application every second:

  1. Real DOM are much heavier than virtual DOM. Rendering the whole application to real DOM can be expensive.
  2. Elements will lose their states. For example, <input> will lose their focus whenever the application re-mount to the page. See live demo here.

We will solve these problems in the next section.

diff (oldVTree, newVTree)

codesandbox.io/s/0xv007yqnv

Imagine we have a function diff (oldVTree, newVTree) which calculate the differences between the two virtual trees; return a patch function that takes in the real DOM of oldVTree and perform appropriate operations to the real DOM to make the real DOM looks like the newVTree.

If we have that diff function, then we could just re-write our interval to become:

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
import diff from './vdom/diff';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
let vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  count++;
  const vNewApp = createVApp(count)
  const patch = diff(vApp, vNewApp);

  // we might replace the whole $rootEl,
  // so we want the patch will return the new $rootEl
  $rootEl = patch($rootEl);

  vApp = vNewApp;
}, 1000);

So let's try to implement this diff (oldVTree, newVTree). Let's start with some easy cases:

  1. newVTree is undefined
    • we can simply remove the $node passing into the patch then!
  2. They are both TextNode (string)
    • If they are the same string, then do nothing.
    • If they are not, replace $node with render(newVTree).
  3. One of the tree is TextNode, the other one is ElementNode
    • In that case they are obviously not the same thing, then we will replace $node with render(newVTree).
  4. oldVTree.tagName !== newVTree.tagName
    • we assume that in this case, the old and new trees are totally different.
    • instead of trying to find the differences between two trees, we will just replace the $node with render(newVTree).
    • this assumption also exists in react. (source)
    • Two elements of different types will produce different trees.

src/vdom/diff.js

import render from './render';

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  // (A)
};

export default diff;

If the code reaches (A), it implies the following:

  1. oldVTree and newVTree are both virtual elements.
  2. They have the same tagName.
  3. They might have different attrs and children.

We will implement two functions to deal with the attributes and children separately, namely diffAttrs (oldAttrs, newAttrs) and diffChildren (oldVChildren, newVChildren), which will return a patch separately. As we know at this point we are not going to replace $node, we can safely return $node after applying both patches.

src/vdom/diff.js

import render from './render';

const diffAttrs = (oldAttrs, newAttrs) => {
  return $node => {
    return $node;
  };
};

const diffChildren = (oldVChildren, newVChildren) => {
  return $node => {
    return $node;
  };
};

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return $node => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

export default diff;

diffAttrs (oldAttrs, newAttrs)

Let's first focus on the diffAttrs. It is actually pretty easy. We know that we are going to set everything in newAttrs. After setting them, we just need to go through all the keys in oldAttrs and make sure they all exist in newAttrs too. If not, remove them.

const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  // setting newAttrs
  for (const [k, v] of Object.entries(newAttrs)) {
    patches.push($node => {
      $node.setAttribute(k, v);
      return $node;
    });
  }

  // removing attrs
  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push($node => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return $node => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};

Notice how we create a wrapper patch and loop through the patches to apply them.

diffChildren (oldVChildren, newVChildren)

Children would be a little bit more complicated. We can consider three cases:

  1. oldVChildren.length === newVChildren.length
    • we can do diff(oldVChildren[i], newVChildren[i]) where i goes from 0 to oldVChildren.length.
  2. oldVChildren.length > newVChildren.length
    • we can also do diff(oldVChildren[i], newVChildren[i]) where i goes from 0 to oldVChildren.length.
    • newVChildren[j] will be undefined for j >= newVChildren.length
    • But this is fine, because our diff can handle diff(vNode, undefined)!
  3. oldVChildren.length < newVChildren.length
    • we can also do diff(oldVChildren[i], newVChildren[i]) where i goes from 0 to oldVChildren.length.
    • this loop will create patches for each already existing children
    • we just need to create the remaining additional children i.e. newVChildren.slice(oldVChildren.length).

To conclude, we loop through oldVChildren regardless and we will call diff(oldVChildren[i], newVChildren[i]).

Then we will render the additional children (if any), and append them to the $node.

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(newVChildren));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    $parent.childNodes.forEach(($child, i) => {
      childPatches[i]($child);
    });

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

I think it is a little bit more elegant if we use the zip function.

import render from './render';

const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

Finalised diff.js

src/vdom/diff.js

import render from './render';

const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};

const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  // setting newAttrs
  for (const [k, v] of Object.entries(newAttrs)) {
    patches.push($node => {
      $node.setAttribute(k, v);
      return $node;
    });
  }

  // removing attrs
  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push($node => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return $node => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return $node => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

export default diff;

Make our app more complicated

codesandbox.io/s/mpmo2yy69

Our current app doesn't really make full use of the power of our virtual DOM. To show how powerful our Virtual DOM is, let's make our app more complicated:

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
import diff from './vdom/diff';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    ...Array.from({ length: count }, () => createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    })),
  ],
});

let vApp = createVApp(0);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  const n = Math.floor(Math.random() * 10);
  const vNewApp = createVApp(n);
  const patch = diff(vApp, vNewApp);

  // we might replace the whole $rootEl,
  // so we want the patch will return the new $rootEl
  $rootEl = patch($rootEl);

  vApp = vNewApp;
}, 1000);

Our app now will generate a random number n between 0 and 9 and display n cat photos on the page. If you go into the dev tools, you will see how we are "intelligently" inserting and removing <img> depending on n.

Thank you

If you read all the way up to here, I would like to thank you for taking the time to read the whole thing. It is a very very long read! Please leave a comment if you actually read the whole thing. Love you!

Start a personal dev blog on your domain for free and grow your readership.

3.4K+ developers have started their personal blogs on Hashnode in the last one month.

Write in Markdown · Publish articles on custom domain · Gain readership on day zero · Automatic GitHub backup and more

Comments (7)

Jason Knight's photo

This example actually illustrates why I find much of the new ECMAScript to be pointlessly cryptic and needlessly bloated; and why I find this whole "Virtual DOM" thing to be complete nonsense based on nothing more than lies and disinformation.

There seems to be this paranoid unfounded claim being circulated that working directly on the DOM is 'slower' or that there's some sort of 'render' overhead. If you do it RIGHT there is no such thing since the browser's rendering engine does not render changes to the DOM until scripting execution is released!.

If anything, by creating and maintaining a "virtual DOM" -- or as it should be called and was for the first decade and a half of JavaScript in the browser a "document fragment" -- means doubling down on memory use (since you have two copies), doubling down on processing and render time since you have to make the live and the copy, and in general taking something ridiculously simple -- DOM Manipulation -- and making it as painfully difficult to do as possible.

... and over what? A bald-faced LIE about some sort of fantasy-land render overhead that you're going to have anyways since you have to copy it to the live DOM sooner or later?

This is part of what I meant a year or so ago when I asked "Are people just afraid of the DOM?" Even when they "use it" they don't really use it, or understand it...

I mean... well let's use your example. Rather than maintaining a second "virtual" copy (aka a document fragment), you build it on the DOM, and when you want to change values you do so on the DOM. Element.removeChild isn't rocket science, nor is textNode.data anything too complicated to deal with. If you made the count as its own text node, all you'd have to write is the count to textNode.data. If you have more than your new count in images truncate the difference from the end. If your new count is more than the number of live images, just appendChild that many images. It WILL be faster than all that pointless "diff" nonsense and "Virtual DOM" waste that just introduces overhead for nothing.

Let's start with three simple little helper functions, treat them as equivalent to your various dependency hell 'include'.

function make(tag, parent, attr, content) {
    tag = d.createElement(tag);
    if (attr) for (var i in attr) tag.setAttribute(i, attr[i]);
    if (content) node_add(tag, content);
    if (parent) parent.appendChild(tag);
    return tag;
}

function node_add(e, newNode) {
    return e.appendChild(
        'object' == typeof newNode ? newNode : d.createTextNode(newNode)
    );
}

function node_stripEnd(e, count, from) {
    if (!from) from = e.lastChild;
    while (from && count--) {
        var next = from.previousSibling;
        e.removeChild(from);
        from = next;
    }
}

"make" just makes elements applying and placing them as needed. (way gutted down from my 'proper' make routine which is far more powerful). "node_add" is just a shorthand for appendChild that lets us not worry or think about if we're adding a new DOM node or just plain text. "node_stripEnd" lets you strip children off the end of an element, or working upwards from (and including) a starting element. We don't need that latter functionality, but this is a straight cut and paste from my cheatsheet.

Then our actual logic:

var
    count = 0,
    appDIV = make('div', d.body, { id : 'app' }, 'The current count is '),
    countText= node_add(appDIV, count),
    cloneIMG = make('img', false, {
        src : 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
    });

function updateApp() {
    var newCount = Math.floor(Math.random() * 10);
    if (newCount < count) {
        node_stripEnd(appDIV, count - newCount);
        count = newCount;
    } else while (count < newCount) {
        count++;
        node_add(appDIV, cloneIMG.cloneNode());
    }
    countText.data = count;
}

updateApp();
setInterval(updateApp, 1000);

No goofy hard to follow arrow functions, no need for let/const (at most I'd wrap it in a SIF / SEF / IIFE / PickANameForItAlready) since lifting actually reduces the memory thrashing, etc, etc.

We set our count to zero so we can track it, make a DIV#app directly on BODY (since it's a scripting only element it has no business in the markup) with "The current count is " text already attached. Then making the countText its own independent textNode lets us just "countText.data=" when it comes time to update the count. Finally we create an image node to clone since for now we're just using all the same image.

The update function as a standalone can be called directly and timeout, giving us our initial render of counted elements and the interval refresh. All it needs to do is pick a new count and if there are less new ones than old ones we strip off the excess, otherwise we add the difference. Finally just update the count shown in the textNode to match.

Call updateApp to draw some children on initialization, set the timeout to update, done.

A fraction the code and logic, dozens of times easier to follow, runs way faster, and works all the way back to IE 5.x

Live demo here:

cutcodedown.com/for_others/jasonYu

That is why I find this whole "Virtual DOM" thing to be pointless, wasteful, and based on lies. I fail to understand -- apart from broken/bad methodology and/or a complete failure to grasp how browser render engines and JS interact -- how this fairy-tale nonsense about "render time" comes into it, how directly working with the live DOM is somehow magically "Bad", what people mean when they talk about "Side effects" since in most cases what they are talking about is stuff I expect to happen, want to happen, and as such aren't side effects it's what they're flipping for, etc, etc, etc.

I'm either missing something painfully obvious, or my way of looking at solving problems is radically different from RoW. (rest of world)

Though I really think if JS app developers spent more time thinking like website developers, they'd realize how foolish so many of these approaches to doing things are.

Show +4 replies
Jason Knight's photo

Jason Yu

I think you misunderstood the point of SSR too. One of the main reasons why people want SSR is for better SEO.

I don't think you're understanding me... ONE of the reasons, sure, but do you know WHY that's one of the reasons and why SSR works for such?

Because it means the server is spitting out flat HTML and using normal page loads. Hopefully, if all goes well, that HTML will be semantic, logically structured, and accessible to as many users as possible -- since again, what did Matt Cutts tell us over a decade ago that still holds true when it comes to on page SEO?

Write for the user, not the search engine

That's what!

On page SEO is an extension of semantic markup and graceful degradation, aka basic accessibility. A well crafted web page should function scripting off, CSS off, images off, it should even make a degree of sense if you just strip-tags it; because every single fancy bit you layer on top of the content should be an optional enhancement, not base functionality. That's why CSR is an epic /FAIL/ if used on websites, and really should be limited to full stack applications where you have no plans to run in on the web in a normal browser; limiting yourself to a single parser, renderer, and JS engine. (like Blink+V8)

EXCEPT when CSR is used as an enhancement atop SSR, which is entirely possible just to avoid those "evil pageloads" so many scripting fanboys run around screaming about and use as justification for telling users with accessibility needs where to stick it.

There are dozens of valid user-agents a well written web page -- the target of SSR only or enhanced SSR -- should be working for; screen readers (software that reads the page aloud), braille readers (like the one I'm using on and off as my vision worsens), teletype, tty, puffers... and most of them are near impossible to use with scripting-only pages, or presentational markup, or failure to separate presentation from content, or any of the dozen other things that mean jack to them. They don't have eyes, so things like layout and colours mean nothing... and remember the joke: Search engines don't have eyeballs. They too could give a flying purple fish about layout, colours, borders, or any of that -- and honestly the Goog making ANY efforts to pay attention to scripting (or style) apart from checking for abuse is a colossal mistake on their part. It's letting too many sleazy fly-by-night dirtbags rip out their handicapped ramps, park in the blue marked spaces, and push over the little old lady with the walker who's in their way.

So, if for accessibility reasons -- of which SEO is part -- you should be using SSR, which by definition means you should be outputting markup... and as accessibility is the actual reason for it you should also have a separation of presentation (CSS and other presentation) from Content (markup/semantics/actual flipping content)... That means you should on the server side be outputting semantic HTML...

So again, if that's what you're supposed to be outputting, why the blazes are you creating a server-side DOM?!? Abstraction is ok, abstraction for nothing is just taking the simplest thing -- outputting HTML -- and turning it into a convoluted mess wasting massive amounts of code on nothing of value.

Though you are right to a degree, I do prefer the low-level way assuming one can call just outputting HTML "low level" which I most certainly would not. Far too many of these abstractions result in more code, more bloat, and make the simplest of things as insanely difficult as possible. Much like the idiocy of HTML/CSS frameworks, enfeebled scripting frameworks like jquery, utter gibberish wastes of time like SCSS, and so forth end up making people write two to ten times the code they need using a library that's twenty times the code they should have.

How that is simpler, easier, better, "aids in collaboration" or "when working with teams" is utterly and completely beyond my comprehension -- and as I've said several times the only way I could see interpreting any of this stuff as useful is if they simply do not know enough HTML and CSS to be working on anything that actually outputs HTML.

A suspicion that gets a lot of confirmation from the code examples people vomit up for these things. I can't even get through React's tutorial -- that tic tac toe example? -- without walking away thinking "These clowns aren't qualified to write a single blasted line of HTML!"

Hence said example needing them to write 6k of code to deliver 9.6k of code that relies on 800k+ of bloated library files to do the job of 5.5k of code written in a sane and rational manner without their BS.

But tell me again how that's magically "easier", or "better", or even worth doing.

You find this virtual DOM stuff useful, good luck to you. From where I sit I swear you're making ten times as much work out of it, and making it thousands of times more complex. If it seems "simpler" thanks to the abstraction, that's the illusion of false simplicity. It "looks" simpler probably because you don't know HTML as well as you think you do.

Though I'll freely admit, I probably don't know JavaScript as well as I think I do. Probably because much like with HTML/CSS frameworks most of my work with it involves ripping it out wholesale from places where it's screwing over the site owner and their customers. A lot of the new ECMAScript 6 stuff just leaves me screaming "WHY?!?" Who thought this was a good idea?!?"

Jason Knight's photo

Oh, and could you clarify:

Decoupling the real DOM from the application. Allowing application to be rendered in a different environment; e.g. server-side rendering.

What does that even mean and/or why is that even desirable. The application server-side shouldn't give a flying purple fish about the DOM -- an inherently client-side concept -- on the server side. It has one job, to output HTML. If you're working in SSR that's it, that's all you should care about... because that's what the accessible output for a website should be.

Enabling developers to write their application declaratively instead of imperatively.

That is a "career educator" distinction I've always found to be pedantic nonsense in application. Well, that's not entirely fair, it's just that for some reason people automatically favor one to the complete exclusion of the other ; which in cases like this amounts to nothing more than hammering a square peg into a round hole. Again, is it REALLY so hard to write server side code that just outputs HTML? All those folks who've spent decades writing websites using PHP, Perl, Java, etc, are pointing their fingers and laughing at that one.

It's like the minute people started using JavaScript on the server, they instantly ignored the easiest and most obvious solution. Probably why they had to give what every other language that's ever been used to build conventional websites the fancy "server side rendering" name; like a cut rate bargain basement marketing slogan.

... and that's the real laugh of it. Until people started AJAXing in content to avoid those "evil" pageloads -- because... reasons? -- everything was SSR. With languages where your example -- as SSR without the interval update since that's a CSR only concept -- would simply be:

<?php
echo '
    The current count is ', $count = mt_rand(0, 10);
while ($count--) echo '
    <img 
        src="https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif"
        alt="describe this, alt is not optional"
    >';
?>

But tell me again how you need a DOM for that server-side. I don't buy it -- it's exactly the type of "pointless convoluted bloat for nothing" that seems to rule this industry right now. -- that again my day job freelancing the past seven or eight years has involved grabbing onto in a senstive spot and ripping it out with great passion and ferocity.

How is writing ten times the code that relies on 10 to 100 times the code you should have in total in pointless libraries/includes/modules/frameworks/whatever "easier" or "better"? What possible point does this entire "virtual DOM" business have other than creating a second DOM on the server that has to be turned back into plain Jane simple HTML to be used for SSR? How is that convoluted mess of code "easier" than just writing a fraction the code in flat plain Jane HTML?

That it has become synonymous with how so many node.js apps are written should be sending anyone even remotely familiar with using HTML properly running and screaming away from it. Pennywise? Pinhead? Freddy? Annabelle? Weeping Angels? Amateurs compared to the "Virtual DOM" when it comes to absolute horror.

To the point I think the only thing scarier might be Showgirls.