Profile picture of Marc Backes (very handsome) Profile picture of Marc Backes (very handsome)
Marc Backes

Create Your Own Virtual DOM

March 29, 2020 ยท by Marc Backes
Profile picture of Marc Backes (very handsome) Profile picture of Marc Backes (very handsome)
Create Your Own Virtual DOM Create Your Own Virtual DOM
I
n this third part of the series, we get our hands dirty and build a virtual DOM engine in less than 100 lines of code. Each step explained in detail.

This post is the third part of a series called Create Your Own Vue.js From Scratch, where I teach you how to create the fundamentals of a reactive framework such as Vue.js. To follow this blog post, I suggest you first read the other parts of the series.

This post might be lengthy at first, but probably not as technical as it looks like. It describes every step of the code; that's why it looks pretty complicated. But bear with me, all of this will make perfect sense at the end ๐Ÿ˜Š

Building the Virtual DOM

The skeleton

In the second part of this series, we learned about the basics of how the virtual DOM works. You copy the VDOM skeleton from the last point from this gist. We use that code to follow along. You'll also find there the finished version of the VDOM engine. I also created a Codepen, where you can play around with it.

Creating a virtual node

So, to create a virtual node, we need the tag, properties, and children. So, our function looks something like this:

function h(tag, props, children){ ... }

(In Vue, the function for creating virtual nodes is named h, so that's how we're going to call it here.)

In this function, we need a JavaScript object of the following structure.

{
    tag: 'div',
    props: {
        class: 'container'
    },
    children: ...
}

To achieve this, we need to wrap the tag, properties, and child nodes parameters in an object and return it:

function h(tag, props, children) {
  return {
    tag,
    props,
    children,
  }
}

That's it already for the virtual node creation.

Mount a virtual node to the DOM

What I mean with mount the virtual node to the DOM is, appending it to any given container. This node can be the original container (in our example, the #app-div) or another virtual node where it will be mounted on (for example, mountaing a <span> inside a <div>).

This will be a recursive function, because we will have to walk through all of the nodes' children and mount the to the respective containers.

Our mount function will look like this:

function mount(vnode, container) { ... }

1) We need to create a DOM element

const el = (vnode.el = document.createElement(vnode.tag))

2) We need to set the properties (props) as attributes to the DOM element:

We do this by iterating over them, like such:

for (const key in vnode.props) {
    el.setAttribute(key, vnode.props[key])
}

3) We need to mount the children inside the element

Remember, there are two types of children:

  • A simple text
  • An array of virtual nodes

We handle both:

// Children is a string/text
if (typeof vnode.children === 'string') {
  el.textContent = vnode.children
}

// Chilren are virtual nodes
else {
  vnode.children.forEach((child) => {
    mount(child, el) // Recursively mount the children
  })
}

As you can see in the second part of this code, the children are being mounted with the same mount function. This continues recursively until there are only "text nodes" left. Then the recursion stops.

As the last part of this mounting function, we need to add the created DOM element to the respective container:

container.appendChild(el)

Unmount a virtual node from the DOM

In the unmount function, we remove a given virtual node from its parent in the real DOM. The function only takes the virtual node as a parameter.

function unmount(vnode) {
  vnode.el.parentNode.removeChild(vnode.el)
}

Patch a virtual node

This means taking two virtual nodes, compare them, and figure out what's the difference between them.

This is by far the most extensive function we'll write for the virtual DOM, but bear with me.

1) Assign the DOM element we will work with

const el = (n2.el = n1.el)

2) Check if the nodes are of different tags

If the nodes are of different tags, we can assume that the content is entirely different, and we'd just replace the node entirely. We do this by mounting the new node and unmounting the old one.

if (n1.tag !== n2.tag) {
  // Replace node
  mount(n2, el.parentNode)
  unmount(n1)
} else {
  // Nodes have different tags
}

If the nodes are of the same tags; however, it can mean two different things:

  • The new node has string children
  • The new node has an array of children

3) Case where a node has string children

In this case, we just go ahead and replace the textContent of the element with the "children" (which in reality is just a string).

...
    // Nodes have different tags
    if (typeof n2.children === 'string') {
        el.textContent = n2.children
    }
...

4) If the node has an array of children

In this case, we have to check the differences between the children. There are three scenarios:

  • The length of the children is the same
  • The old node has more children than the new node. In this case, we need to remove the "exceed" children from the DOM
  • The new node has more children than the old node. In this case, we need to add additional children to the DOM.

So first, we need to determine the common length of children, or in other terms, the minimal of the children count each of the nodes have:

const c1 = n1.children
const c2 = n2.children
const commonLength = Math.min(c1.length, c2.length)

5) Patch common children

For each of the cases from point 4), we need to patch the children that the nodes have in common:

for (let i = 0; i < commonLength; i++) {
  patch(c1[i], c2[i])
}

In the case where the lengths are equal, this is already it. There is nothing left to do.

6) Remove unneeded children from the DOM

If the new node has fewer children than the old node, these need to be removed from the DOM. We already wrote the unmount function for this, so now we need to iterate through the extra children and unmount them:

if (c1.length > c2.length) {
  c1.slice(c2.length).forEach((child) => {
    unmount(child)
  })
}

7) Add additional children to the DOM

If the new node has more children than the old node, we need to add those to the DOM. We also already wrote the mount function for that. We now need to iterate through the additional children and mount them:

else if (c2.length > c1.length) {
    c2.slice(c1.length).forEach(child => {
        mount(child, el)
    })
}

That's it. We found every difference between the nodes and corrected the DOM accordingly. What this solution does not implement though, is the patching of properties. It would make the blog post even longer and would miss the point.

Rendering a virtual tree in the real DOM

Our virtual DOM engine is ready now. To demonstrate it, we can create some nodes and render them. Let's assume we want the following HTML structure:

<div class="container">
  <h1>Hello World ๐ŸŒ</h1>
  <p>Thanks for reading the marc.dev blog ๐Ÿ˜Š</p>
</div>

1) Create the virtual node with h

const node1 = h('div', { class: 'container' }, [
  h('div', null, 'X'),
  h('span', null, 'hello'),
  h('span', null, 'world'),
])

2) Mount the node to the DOM

We want to mount the newly created DOM. Where? To the #app-div at the very top of the file:

mount(node1, document.getElementById('app'))

The result should look something like this:

VDOM Demo

3) Create a second virtual node

Now, we can create a second node with some changes in it. Let's add a few nodes so that the result will be this:

<div class="container">
  <h1>Hello Dev ๐Ÿ’ป</h1>
  <p>
    <span>Thanks for reading the </span><a href="https://marc.dev">marc.dev</a
    ><span> blog</span>
  </p>
  <img
    src="https://media.giphy.com/media/26gsjCZpPolPr3sBy/giphy.gif"
    style="width: 350px; border-radius: 0.5rem;"
  />
</div>

This is the code for creating that node:

const node2 = h('div', { class: 'container' }, [
  h('h1', null, 'Hello Dev ๐Ÿ’ป'),
  h('p', null, [
    h('span', null, 'Thanks for reading the '),
    h('a', { href: 'https://marc.dev' }, 'marc.dev'),
    h('span', null, ' blog'),
  ]),
  h(
    'img',
    {
      src: 'https://media.giphy.com/media/26gsjCZpPolPr3sBy/giphy.gif',
      style: 'width: 350px; border-radius: 0.5rem;',
    },
    []
  ),
])

As you can see, we added some nodes, and also changed a node.

4) Render the second node

We want to replace the first node with the second one, so we don't use mount. What we want to do is to find out the difference between the two, make changes, and then render it. So we patch it:

setTimeout(() => {
  patch(node1, node2)
}, 3000)

I added a timeout here, so you can see the code DOM changing. If not, you would only see the new VDOM rendered.

Summary

That's it! We have a very basic version of a DOM engine which lets us:

  • Create virtual nodes
  • Mount virtual nodes to the DOM
  • Remove virtual nodes from the DOM
  • Find differences between two virtual nodes and update the DOM accordingly

You can find the code we did in this post, on a Github Gist I prepared for you. If you just want to play around with it, I also created a Codepen, so you can do that.