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 🚘
- Introduction
- Virtual DOM basics
- Implementing the virtual DOM & rendering (this post)
- Building reactivity
- 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){ ... }
(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:
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.
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.