With the popularity of React, the internals and implementation of Virtual DOM has becoming top discussed topic in tech communities and interviews. This post will give an introduction of Virtual DOM and how to implement a simple Virtual DOM logic.
How to understand Virtual DOM
In early days, front end developers would update a webpage view based on the data status change(usually after making AJAX call). But it brings performance penalties when there is frequent update as it would cause page reflow and repaint and in turn lead to page stuck.
Hence people comes out a solution which is to just update the node which is actually updated instead of updating the whole node tree. This process involves comparing the existing node tree with the updated node tree to find out the difference and then do the update. Instead of using the real DOM representation, people use JS objects represent the nodes and do this kind of comparison. This is what Virtual DOM concept comes into the picture, it is a layer between DOM and JS.
How to represent Virtual DOM
The Virtual DOM can be represented as a JS object. In ES6, class
can be used to represent Virtual DOM structure. A basic Virtual DOM should have tagName, attributes and child nodes.
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
}
To simplify the call, a helper function can be created to return a new Virtual DOM object instead of calling new every time.
function el(tagName, props, children) {
return new Element(tagName, props, children);
}
Now assume there is below DOM tree.
<div class="test">
<span>span1</span>
</div>
It can be represented in Virtual DOM as
const span = el("span", {}, ["span1"]);
const div = el("div", { class: "test" }, [span]);
Later there is a need to update the real DOM when comparing the diff of Virtual DOM, hence a render
function needs to be defined as well.
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
render() {
const dom = document.createElement(this.tagName);
// update attributes
Reflect.ownKeys(this.props).forEach(name =>
dom.setAttribute(name, this.props[name])
);
// recusive update child nodes
this.children.forEach(child => {
const childDom =
child instanceof Element
? child.render()
: document.createTextNode(child);
dom.appendChild(childDom);
});
return dom;
}
}
How to compare Virtual DOM tree and do update
Now Virtual DOM and its usage have been introduced. Coming to the part on how to compare and update DOM from the Virtual DOM tree. This basically involves the addition, deletion and update operation on a Virtual DOM tree.
The process involves two steps basically:
- diff: recursively compare the difference of Virtual DOM in two Virtual DOM tree at specified index
- patch: Do the update based on the difference of Virtual DOM tree
To do this, there are two ways:
- Run diff on the whole Virtual DOM tree once and then do the update once for all
- Do the update when there is any diff at any Virtual DOM during comparison
Below the second way of doing the comparison and update is introduced.
First put the logic of diff
and patch
into a function called updateEl
. The basic definition is
/**
*
* @param {HTMLElement} $parent
* @param {Element} newNode
* @param {Element} oldNode
* @param {Number} index
*/
function updateEl($parent, newNode, oldNode, index = 0) {
// ...
}
All variables starting with $
are the real DOM elements on the page. index
indicates the location of oldNode in the child nodes $parent element.
Add new node
If oldNode is undefined, it means newNode is a newly added node, just append it to the DOM element.
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
}
}
Delete a node
If newNode is undefined, it means there is no node at the corresponding index in the DOM tree, just remove the node from the DOM tree.
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
}
}
Update a node
Three cases are considered as update while comparing oldNode to newNode:
- The type of the node change, text to Virtual DOM or Virtual DOM to text
- Both nodes are text nodes but content changes
- The attributes of nodes change
Let's define three Symbols to represent these three cases.
const CHANGE_TYPE_TEXT = Symbol("text");
const CHANGE_TYPE_PROP = Symbol("props");
const CHANGE_TYPE_REPLACE = Symbol("replace");
For attribute change, a function to replace attributes needs to be defined.
function replaceAttribute($node, removedAttrs, newAttrs) {
if (!$node) {
return;
}
Reflect.ownKeys(removedAttrs).forEach(attr => $node.removeAttribute(attr));
Reflect.ownKeys(newAttrs).forEach(attr =>
$node.setAttribute(attr, newAttrs[attr])
);
}
And there is a need for another function to check what case it is for the change.
function checkChangeType(newNode, oldNode) {
if (
typeof newNode !== typeof oldNode ||
newNode.tagName !== oldNode.tagName
) {
return CHANGE_TYPE_REPLACE;
}
if (typeof newNode === "string") {
if (newNode !== oldNode) {
return CHANGE_TYPE_TEXT;
}
return;
}
const propsChanged = Reflect.ownKeys(newNode.props).reduce(
(prev, name) => prev || oldNode.props[name] !== newNode.props[name],
false
);
if (propsChanged) {
return CHANGE_TYPE_PROP;
}
return;
}
In updateEl
, it will do corresponding update based on the return value of checkChangeType
, if return value is empty, no change is needed.
function updateEl($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
if (changeType === CHANGE_TYPE_TEXT) {
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
}
}
Recursive diff and patch
If none of the above three cases happens, it means the current Virtual DOM doesn't have any update, now need to move to its children and do the same comparison and update.
function updateEl($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
if (changeType === CHANGE_TYPE_TEXT) {
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
} else if (newNode.tagName) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; ++i) {
updateEl(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}
The real world code implementation for Virtual DOM is much more complex in those mature frameworks. But above is simple explanation about how Virtual DOM work and should give you a general idea on Virtual DOM.