Imagine you are building your house. One day you want to add a new kitchen island - so you rebuild the house from scratch. Then you want to repaint the house, so you again rebuild the whole house. Then it's time to change your window panes into, so you rebuild it from scratch...again. Unless you are Fix-It Felix, this is not the way to make house updates.
Instead, you should have a blueprint of the house. To add a kitchen island, you find which area will be affected on the blueprint and only rebuild that area. If you want to repaint, calculate the wall perimeter area from blueprint, move out all the stuff next to the wall (just don't do this please), and start painting. If you want to change your window panes, locate all windows from blueprint and replace them.
The same can be said about DOM. Think of HTML DOM as a house and virtual DOM as blueprint of the house. We should use virtual DOM to help us make changes to our DOM. This post is largely inspired by Jason Yu's Building a Simple Virtual DOM from Scratch video (I am not affiliated with him, but I found his stuff super helpful. You should check him out!). This is a shortened and simplified version. My hope is that readers who are new with virtual DOM will gain better understanding what virtual DOM is.
The code can be found in this github repo. This post is divided into 6 steps:
Let's get started!
Before we even begin, make sure we have latest node ready. Create a folder and cd into it, start an NPM project (npm init -y
). Create index.html
and vdom.js
in root directory. For quick bundling, we'll use parcel-bundler
so run npm install parcel-bundler
. I also like having "start": "parcel index.html" in package.json.
My index.html
looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Basic Virtual Dom Demo</title>
</head>
<body>
<h1>Virtual Dom Demo</h1>
<div id="app"></div>
<script src="./vdom.js"></script>
</body>
</html>
Just make sure to import vdom.js
and have something like <div id="app"></div>
to mount our DOM later.
Virtual DOM is nothing but a javascript object that represents DOM nodes. As mentioned earlier, virtual DOM to DOM is what a blueprint is to a house. A house is physical, expensive to update, while a blueprint is just a piece of paper and much easier to update.
This is what our virtual DOM looks like:
const vAppStructure = num => {
return {
tagName: "ul",
text: "",
attrs: { class: "parent-class", id: `parent-id-${num}` },
children: [
{
tagName: "li",
attrs: "",
text: "list 1",
attrs: { class: "child-class" },
children: []
},
{
tagName: "li",
attrs: "",
text: "list 2",
attrs: { class: "child-class" },
children: [{ tagName: "input", attrs: "", text: "", children: [] }]
}
]
};
};
Observations:
<ul>
element with 2 <li>
children.We have a virtual DOM structure now. We should render it into DOM nodes. The main Javascript APIs needed in this post are: document.createElement
, Element.setAttribute
, document.createTextNode
, and Element.appendChild
. First to create element, second to set attributes, third to deal with text, and fourth to attach any child into parent. You'll see $
notation throughout the codes - variables with $
represent DOM nodes.
const renderer = node => {
const { tagName, text, attrs, children } = node;
const $elem = document.createElement(tagName);
for (const attr in attrs) {
$elem.setAttribute(attr, attrs[attr]);
}
if (text) {
const $text = document.createTextNode(text);
$elem.appendChild($text);
}
if (children && children.length > 0) {
for (const child of children) {
const $child = renderer(child);
$elem.appendChild($child);
}
}
return $elem;
};
Observations:
tagName
that we have in virtual DOM is rendered using document.createElement
. attrs
is iterated and is set onto that newly-created-element.Now that we have DOM nodes created, attributes and text appended, and children rendered and appended - these DOM nodes can't wait to be attached into our HTML file, so let's mount it!
Think of mounting as placing our nodes into HTML page. We will use document.replaceWith
.
const mount = ($nodeToReplace, $nodeTarget) => {
$nodeTarget.replaceWith($nodeToReplace);
return $nodeToReplace;
};
Now we have all the functions we need. Let's set up some selectors and mount it:
const app = document.querySelector("#app");
let num = 10;
let currentVApp = vAppStructure(num);
let $vApp = renderer(currentVApp);
mount($vApp, app);
You can run parcel index.html
(or npm run start
) and watch your virtual DOM displayed in HTML! Super cool. You have rendered your own HTML page using pure Javascript with virtual DOM! This is basic virtual DOM and it is powerful. Next we will explore the power of virtual DOM by updating it periodically.
The power of virtual DOM is whenever you update your JS object without needing screen refresh.
To demonstrate updating, we will use setInterval
to increase the number per second.
let $rootElem = mount($vApp, app);
let newVApp;
setInterval(() => {
num++;
newVApp = vAppStructure(num);
let $newVApp = renderer(newVApp);
$rootElem = mount($newVApp, $rootElem);
currentVApp = newVApp;
}, 1000);
Now if you open up devTools and observe the id of ul
element - it is now increasing by 1. Sweet! We have a working, self-updating DOM node. Beautiful!!
Observations:
Let's do it the right way!
One of the most popular Frontend library in the world, React, uses virtual DOM. The way React treats virtual DOM is by diffing.
We will create a (very) simplified version of diffing.
const diff = (oldVApp, newVApp) => {
const patchAttrs = diffAttrs(oldVApp.attrs, newVApp.attrs);
return $node => {
patchAttrs($node);
return $node; // important to return $node, because after diffing, we patch($rootElem) and it expects to return some sort of element!
};
};
export default diff;
Observations:
The diffAttrs
function looks like this;
const diffAttrs = (oldAttrs, newAttrs) => {
const patches = [];
for (const attr in newAttrs) {
patches.push($node => {
$node.setAttribute(attr, newAttrs[attr]);
return $node;
});
}
for (const attr in oldAttrs) {
if (!(attr in newAttrs)) {
patches.push($node => {
$node.removeAttribute(attr);
return $node;
});
}
}
return $node => {
for (const patch of patches) {
patch($node);
}
};
};
Observations:
text
, children
, tagName
. For the sake of brevity I skipped them. The logic is similar though.id="my-id-2"
), it will set that new id into the element node.attr
in oldAttrs
. The assumption is if an attribute is found in oldAttrs
that does not exist in newAttrs
, that attribute must have gotten removed, so we delete it.Our updated setInterval will look like this:
setInterval(() => {
num++;
newVApp = vAppStructure(num);
const patch = diff(currentVApp, newVApp);
$rootElem = patch($rootElem);
currentVApp = newVApp;
}, 1000);
Observations:
input
now works as expected.To recap, here is what we learned:
Virtual DOM is a plain JS object describing what a DOM should look like, like a blueprint of a house (whereas a DOM is like a house).
Mounting virtual DOM is a process of iterating virtual DOM properties and calling setElement
, createTextNode
, setAttribute
, and appendChild
(there are more APIs needed in more complicated app ).
The best way to update our app is not to replace the entire DOM structure per update (it will force other element to re-render unnecessarily like input
), but to go through each attribute in each element and set new attributes. Doing this will not re-render the element.
This is far from perfect - it is a simplified representation of what React/ other framework does.
Thanks for reading this. Appreciate you spending your time and reading! If you have any questions, found mistakes, please feel free to drop by comments. Let me know what new thing you learned from this!
Some resources I found helpful: