Building a Simple Virtual DOM from Scratch
YCM Jason

YCM Jason @ycmjason

About: I cook delicious TypeScript / JavaScript nuggets. 🍳

Location:
London, UK
Joined:
Sep 30, 2017

Building a Simple Virtual DOM from Scratch

Publish Date: Dec 5 '18
355 62

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');
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

$ mkdir /tmp/vdommm
$ cd /tmp/vdommm
Enter fullscreen mode Exit fullscreen mode

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

$ git init
$ gitignore init node
$ npm init -y
Enter fullscreen mode Exit fullscreen mode

Let's do out initial commit.

$ git add -A
$ git commit -am ':tada: initial commit'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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>
Enter fullscreen mode Exit fullscreen mode

src/main.js

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

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

package.json

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

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.
Enter fullscreen mode Exit fullscreen mode

Going to http://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)

https://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,
  };
};
Enter fullscreen mode Exit fullscreen mode

With object destructuring we can write the above like this:

src/vdom/createElement.js

export default (tagName, { attrs, children }) => {
  return {
    tagName,
    attrs,
    children,
  };
};
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

Recall the virtual DOM that we created before:

src/main.js

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

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

It now can be written as:

src/main.js

import createElement from './vdom/createElement';

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

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

render (vNode)

https://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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

mount ($node, $target)

https://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>
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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

Let's make our app more interesting

https://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'));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)

https://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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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 - 1.
  2. oldVChildren.length > newVChildren.length
    • we can also do diff(oldVChildren[i], newVChildren[i]) where i goes from 0 to oldVChildren.length - 1.
    • 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 - 1.
    • 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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Make our app more complicated

https://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);
Enter fullscreen mode Exit fullscreen mode

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!

Comments 62 total

  • Lee C
    Lee CDec 5, 2018

    Hey, really great write up. I missed the talk at Manchester Web Meetup, but wanted to see this talk, when I first learned React a few years I looked at how the Virtual DOM was put together and did a similar createElement/mount function pair, but I think the real power on the Virtual DOM, especially when used in React is the diffing mechanism. I didnt attempt that at the time, but I think what you have put above is great because I think it helps show why/how frameworks like React update themselves in reaction to changes and you do it with understandable and clean code, awesome :D

    • YCM Jason
      YCM JasonDec 5, 2018

      Thank you so much! Writing clean and elegant code is my passion. I tried to keep things clean so that people can understand it.

      • Lee C
        Lee CDec 5, 2018

        I'm the same, I think there is a lot to be gained from clean code, not just for yourself, but for other developers that do (or will) work with it too :) Doing it in example/tutorial code even more so as it improves the value and quality of the article, like you have here :)

        • YCM Jason
          YCM JasonDec 5, 2018

          I am so glad I found someone who appreciate this. ❤❤

  • Thoby V ijishakin
    Thoby V ijishakinDec 5, 2018

    This is the kind of article that makes me feel so Lucky to be alive at this moment and be part of this code side of the world which is filled with extremely passionate and knowledgeable people who really make efforts to share their knowledge with others.

    It's because of engineers like you that it's actually approachable to not only learn to code, but learn to make amazing things.

    Forgive me for saying so much, I just happen to really appreciate this, even if I'm not thinking of building a Vdom right now.. Knowing how things work behind the wheel feels very good.

    Thank you.

    • YCM Jason
      YCM JasonDec 5, 2018

      It's because of readers like you that make me feel motivated to keep writing! Your comment almost made me teared 😭! Thank you so much!!!! ♥️♥️♥️

    • Nick Mudge
      Nick MudgeApr 7, 2020

      I love your comment!

  • Samuel Andreo
    Samuel AndreoDec 6, 2018

    I could attend to the talk and it was perfect. I really appreciate and value the ability to share knowledge on such an interesting and cutting-edge topic as this. Thanks mate ;)

    • YCM Jason
      YCM JasonDec 6, 2018

      ❤❤❤ thank you so much!!!!

      • YCM Jason
        YCM JasonDec 6, 2018

        share the post if you liked it! spread the knowledge! :)

  • YCM Jason
    YCM JasonDec 9, 2018

    Bless you too Linus!

  • satyajeet kumar jha
    satyajeet kumar jhaDec 11, 2018

    Halfway through the article but you are real genius .Great contribution .

    • YCM Jason
      YCM JasonDec 11, 2018

      Thank you!!! Thanks for taking the time to read through this. This is a very very long article I know. Should have made this a series of articles really haha.

  • YCM Jason
    YCM JasonDec 19, 2018

    Thank you so much for your kind words! I am very very glad to see you enjoying the post!

    I used to think that the virtual DOM was some kind of evil magic I'd never understand, but now I can confidently say that I do.

    This is one of the main reasons why I did this topic!! I am glad this presentation achieved its goal!

    Please ask the questions! Would be nice if I can clear things further!

  • YCM Jason
    YCM JasonDec 19, 2018

    Sure. You have stepped into the common trap of thinking recursion recursively. Your brain will stack-overflow first before the computer does haha.

    The reason why you are so confused is because you are lacking the "leap of faith" for recursion. You try to figure out what is happening, then you look into the function; it calls itself, and u look into the function again... Then you are lost.

    All you need is faith!

    The first thing is to define what diff and diffChildren do. I made it very clear for diff.

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

    So the idea is, you know diff will somehow call itself again at some point. And when this happens, all you need is the "leap of faith"; believe that diff will work as you wish! Don't look into diff again to try to figure things out! Just think about what diff should do and return. By our definition, it will return a patch! So just assume the recursive calls to diff will work and read on.

    Teaching recursion using this example is a bit hard, have a look at this article which I explained very clearly how you could obtain faith in recursion.

    The leap of faith takes some practice to get use to it. If you want some exercise, I am happy to give you some challenge and guide you through them. Feel free to DM me on twitter: @ycmjason

  • YCM Jason
    YCM JasonDec 20, 2018

    I am sorry that my explanation didn't help. :(

    diff has two base cases:

    1. If the new node is undefined
    2. If the new and old nodes are of different types. This could be either of the cases below:
      1. one of the node is a TextNode while the other one is an ElementNode
      2. Both are ElementNode but with different tag.

    In fact, all my base cases are defined in a guard clause. This means that all the return statement before the last return can be considered as base case.

    • YCM Jason
      YCM JasonDec 20, 2018

      Oops, I just realised there is one more, which is when there is no children in the node. But I didn't explicitly deal with that case as it will be automatically dealt with in the for loop in diffChildren

  • YCM Jason
    YCM JasonDec 20, 2018

    did you write this code simply based on the "leap of faith"?

    Yes. Totally based on the leap of faith. It always work! It's the very important thing you need when dealing with recursion.

    Is this how algorithms like merge sort and quick were written?

    Well, merge sort and quick sort if written in a recursive way, can be reasoned about using the "leap of faith" for sure. Whether or not the original Author has the leap of faith there is noway to find out. 😂😂

    Is leap of faith good enough for serious professional/interview problems?

    Leap of faith will definitely work in professional and interview problems. It's just a mindset you should have when writing recursive solutions, not really a method. Once you do more recursion, you will become confident enough to hold that faith all time.

  • Ant
    AntJan 21, 2019

    really appreciate your sharing, I want to translate this article to Chinese and share to other people in China, I wonder if you agree me to do that, thank you so much。

    • YCM Jason
      YCM JasonJan 22, 2019

      That's a great idea. As long as you credit me, feel free! Please let me proof read before you post it! :)

  • Kirill Vasiltsov
    Kirill VasiltsovMar 1, 2019

    Very good job. Thank you for a thorough guide!
    Actually, there is a typo in your code within the post.

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

    Inside the function that we put into additionalPatches we should render not the newVChildren but the additionalVChild.
    This code appears in the first example in the explanation of diffChildren function.

    • YCM Jason
      YCM JasonMar 2, 2019

      You are a hero! Thanks for pointing that out! 🎉🎉🎉 I'll change it as soon as possible!

  • Martin
    MartinMar 26, 2019

    Hi,

    Thanks a lot for the article, very nicely explained.
    How would you recommend to handle events on the virtual nodes (e.g. add a addEventListener on a node that will influence another node rendering)?

    • YCM Jason
      YCM JasonMar 28, 2019

      since many people asked. i might write an eps 2 that could cover this.

      • Casimir de Bruijn
        Casimir de BruijnApr 5, 2019

        Please do, if you haven't already. I enjoyed your article btw, pinned on reading list.

        Currently, I have the following somewhat working:

        render.ts
        if (events) {
        events.map(([type, event]) => {
        $element.addEventListener(type, event)
        })
        }

        index.ts
        attrs: {...},
        events: [['click',() => {console.log('event handled')}]]

        It seems to work and not to propagate upwards. Not sure about dynamically rendering nested elements or w/e. Thoughts?

  • Alexis Duran
    Alexis DuranAug 22, 2019

    The video was awesome. I am glad you record it.
    Awesome energy and super clear, thanks for sharing.

    Congratz bro! 🚀

  • Steven G.
    Steven G.Aug 25, 2019

    Couldn’t agree more with this comment. I looked into Reacts vdom a while back to better understand and I just got lost and lost. This post was first time I’d seen someone breakdown concisely and in a way that doesn’t overwhelm the reader with “frivolous” complexities

    Well done thanks, Jason!

  • Nhat Khanh
    Nhat KhanhSep 11, 2019

    Found this, will spend my weekends to go through this for sure, thank you.

  • kamalakar
    kamalakarAug 18, 2020

    Thank you for this

  • Michael Maier
    Michael MaierJan 6, 2021

    Thank you very much for the article and video. Great explanation from scratch. I noticed that the GIF animation does not play till the end. Every time count changes, the animation is reset, although the image DOM element is not changed. Does anyone know the reason?

  • Michael Maier
    Michael MaierJan 6, 2021

    The linked CodeSandbox examples don't run. I get

    Cannot read property 'replaceWith' of null

    It seems that a differnt index.html is used in the sandbox. Hence, the target element #app to mount to is not found

    let $rootEl = mount($app, document.getElementById('app'));
    
    Enter fullscreen mode Exit fullscreen mode
  • Michael Maier
    Michael MaierJan 10, 2021

    The version with

    $parent.childNodes.forEach(($child, i) => {
        childPatches[i]($child as HTMLElement)
    })
    
    Enter fullscreen mode Exit fullscreen mode

    does not work correctly if child nodes are removed, since live NodeList ($parent.childNodes) updates the collection automatically when the DOM changes (e.g. child is removed). Hence, forEach is not called for all (original) elements (child nodes). For example, if there are 3 child nodes and the first patch keeps the child and the second and third patch delete the child, then only the first and second patches are called. The third patch is not called, since the second patch removes the node, which removes it from $parent.childNodes.

    It is fixed by the version that uses zip, since it copies the elements of $parent.childNodes into an Array, which is not automatically updated when the DOM changes.

  • Arnav Kumar
    Arnav KumarMay 25, 2021

    the way too awesome article I have ever seen about programming 😍

  • Nikki Goel
    Nikki GoelJun 18, 2021

    Amazing. I did go through the whole and implemented it myself also.
    Can I ask for an article or video or both on a similar topic - "Create your own bindings using proxies from scratch".
    It was originally requested in the comments of the YouTube video.

    • YCM Jason
      YCM JasonJul 4, 2021

      bindings as in reactivity?

      • Nikki Goel
        Nikki GoelJul 4, 2021

        Yes. Reactivity in Vue (how DOM updates and render the changes, how changes are detected, etc).
        Also how computed properties and watchers work.
        I would love to see your take on this.

  • innocentperos
    innocentperosFeb 27, 2022

    thank you very much for such a simplified explanation and elegant code. I have been wanting to understand how vuejs virtual dom works.

    Then i recreated your idea i was mind blown by the patch($node) function i was first confuse by how this functions know exactly which element to update on the actual DOM, until i when through the zip function over and over before i understood it.

    I have currently implemented it in typescript added reactiveness, event listener.

    Once again thank you some much, its individuals like you that make coding magical yet understandable 🤩

    • YCM Jason
      YCM JasonApr 3, 2022

      Thank you for your kind words. ❤️

  • Mariusz
    MariuszNov 23, 2022

    Is it '0 to oldVChildren.length' or should that be '0 to oldVChildren.length-1'?

    • YCM Jason
      YCM JasonDec 22, 2022

      nice catch! I will update!

  • johnqueen99
    johnqueen99Dec 4, 2022

    Thank you for the wonderful guide

  • Victor Meneghini
    Victor MeneghiniDec 15, 2022

    Hey! This article is gold (:

    I didn't even notice the time pass while reading! I will follow u to check your contents! Well done my friend :D

  • Kaung Htet
    Kaung HtetFeb 6, 2023

    Last whole week i am researching about reactive, and virtual dom to make my hobby javascript library. I am stacking around creating virtual dom and diffing. I saw your post
    and it help me alot. I'm really thank you for your explanation and i saw you in video and you're so active in that and full of energy. And again Thank you :)

  • Tom F.
    Tom F.Jul 27, 2023

    Great content. I am wondering why you don't use Proxy objects instead of this complex diff function.

    • YCM Jason
      YCM JasonAug 11, 2023

      Because Proxy wasn't ready at the time of writing this article. How do you think Proxy can be used here to improve this?

      • Tom F.
        Tom F.Aug 11, 2023

        You can create a proxy object out of a regular object, then you can listen to any change to the object and execute some code against those changes : javascript.info/proxy

  • Steven Slick
    Steven SlickMay 4, 2024

    I've created a to-do app using this simple virtual DOM but I've got problems that the code does not handle deletion and reordering of real DOM element's

    • Nawal Husnoo
      Nawal HusnooMay 4, 2025

      @ycmjason Thanks for writing this! I was also thinking, if your old tree has

      [pA, input, imgB]
      and you remove the first element, then zipping would pair up
      (pA,input), (input, imgB)
      , but I think your input would lose its focus/contents?

      (I realise this is a simplified tutorial).

      • YCM Jason
        YCM JasonMay 7, 2025

        indeed indeed! this is very very overly simplify!

Add comment