Part 2 - rendering
This tutorial aims to explain how modern JS frameworks perform rendering via virtual dom.
First of all, why do JS frameworks use “Virtual DOM”?
- modifying actual dom causes browser to re-render stuff (using you GPU)
- decupling logic from DOM allows you to use different rendering targets (like SSR or native mobile rendering)
Here’s an example of how VueJS does it’s rendering:
const { h, createApp } = Vue;
const App = {
render() {
return h("div", {
id: "hello",
}, [
h("span", "world")
])
}
}
will create:
<div id="hello">
<span>world</span>
</div>
Now that we know how Vue does it, let’s try to split the problem into smaller ones.
- function that creates our VNodes (elements in virtual DOM). In Vue’s case that function is called
h
The function should take: - name of the element (
div
,span
, etc.) - properties (
id
,class
, etc.) - children VNodes (just make it recurse)
- function that will be able to create actual DOM elements based on our VNodes. We’ll call this function
mount
. - Lastly, we’re gonna need a function that will take old VNode, new VNode and transform old one into the new one (we’ll call it
patch
).
Let’s start with h
function.
function h(tag, props, children) {
return {
tag,
props,
children
};
};
That was easy. Since VNode
is only an internal object it can look however we want :).
Next is mount
function:
function mount(vnode, container) {
// create the DOM element
const el = vnode.el = document.createElement(vnode.tag);
// assign all the props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key.startsWith("on")) {
// event handler
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
// just a normal prop
el.setAttribute(key, value);
}
}
}
// create it's children
if (vnode.children) {
if (typeof vnode.children === "string") {
// just text
el.textContent = vnode.children;
} else if (vnode.children instanceof Array) {
// For simplification let's assume that if we have an array
// it's only VNodes (without strings mixed in).
vnode.children.forEach(child => {
mount(child, el);
});
} else {
console.error("Invalid typeof VNode children", vnode.children);
}
}
container.appendChild(el);
}
Now we can render our virtual DOM, but to make it update we need a patch
function.
function patch(dom1, dom2) {
// if the tags don't match we need a full replace
if (dom1.tag !== dom2.tag) {
// replace
return;
}
const el = dom1.el = dom2.el;
// props
const oldProps = dom1.props || {};
const newProps = dom2.props || {};
// add or update props
for(const key in newProps) {
const oldValue = oldProps[key];
const newValue = newProps[key];
if (newValue != oldValue) {
el.setAttribute(key, newValue);
}
}
// remove if prop removed
for (const key in oldProps) {
if(!(key in newProps)) {
el.removeAttribute(key);
}
}
// children
const oldChildren = dom1.children;
const newChildren = dom2.children;
if (typeof newChildren === "string") {
el.textContent = newChildren;
} else {
// find common length
const commonLength = Math.min(oldChildren.length, newChildren.length);
// patch each individually - this is a very naive approach, because inserting
// a new child at the beginning forces every single child to re-render
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
if (newChildren.length > oldChildren.length) {
// we need to insert new elements
newChildren.slice(oldChildren.length).forEach(child => {
mount(child, el);
});
} else if (newChildren.length < oldChildren.length) {
// we need to remove elements
oldChildren.slice(newChildren.length).forEach(child => {
el.removeChild(child.el);
});
};
}
}
Finally, you probably also want a small utility to mount your app.
function mountApp(component, container) {
let mounted = false;
let prevVdom = null;
useEffect(() => {
if (!mounted) {
prevVdom = component.render();
mount(prevVdom, container);
mounted = true;
} else {
const newVdom = component.render();
patch(prevVdom, newVdom);
prevVdom = newVdom;
}
});
};