Create Your Own Vue.js From Scratch - Part 3 (Building The VDOM)

Marc Backes - Mar 29 '20 - - Dev Community

Create Your Own Vue.js From Scratch - Part 3 (Building The VDOM)

If you like this article, chances are you'd like what I tweet as well. If you are curious, have a look at my Twitter profile. 🚀

This is this 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 read about the first and second part of this series.

This post might be long at first, but probably not as technical as it looks like. It describe 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 😊

Roadmap 🚘

  1. Introduction
  2. Virtual DOM basics
  3. Implementing the virtual DOM & rendering (this post)
  4. Building reactivity
  5. Bringing it all together

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

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

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

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

1) We need to create a DOM element

const el = (vnode.el = document.createElement(vnode.tag))
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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;',
        },
        [],
    ),
])
Enter fullscreen mode Exit fullscreen mode

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

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.

If you have any more questions about this, feel free to reach out to me via Twitter.

Original cover photo by Joshua Earle on Unplash, edited by Marc Backes.

. . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player