36. Mini project: a virtual-DOM renderer from scratch (~150 lines)
A minimal illustration of §33's VDOM diffing concept, enough to actually run:
// vdom.js — a tiny virtual DOM: h() creates nodes, render() diffs and patches
function h(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
function createRealNode(vnode) {
if (typeof vnode === "string" || typeof vnode === "number") {
return document.createTextNode(String(vnode));
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
if (key.startsWith("on")) el.addEventListener(key.slice(2).toLowerCase(), value);
else el.setAttribute(key, value);
}
vnode.children.forEach((child) => el.appendChild(createRealNode(child)));
vnode.dom = el;
return el;
}
function diff(oldVNode, newVNode) {
if (oldVNode === undefined) return { type: "CREATE", newVNode };
if (newVNode === undefined) return { type: "REMOVE" };
if (typeof oldVNode !== typeof newVNode ||
(typeof oldVNode === "object" && oldVNode.type !== newVNode.type)) {
return { type: "REPLACE", newVNode };
}
if (typeof newVNode === "string" || typeof newVNode === "number") {
return oldVNode !== newVNode ? { type: "TEXT", newVNode } : { type: "NONE" };
}
return {
type: "UPDATE",
propPatches: diffProps(oldVNode.props, newVNode.props),
childPatches: diffChildren(oldVNode.children, newVNode.children),
oldVNode, newVNode,
};
}
function diffProps(oldProps, newProps) {
const patches = [];
for (const key of Object.keys(newProps)) {
if (oldProps[key] !== newProps[key]) patches.push({ key, value: newProps[key] });
}
for (const key of Object.keys(oldProps)) {
if (!(key in newProps)) patches.push({ key, value: null }); // removed
}
return patches;
}
function diffChildren(oldChildren, newChildren) {
const max = Math.max(oldChildren.length, newChildren.length);
const patches = [];
for (let i = 0; i < max; i++) patches.push(diff(oldChildren[i], newChildren[i]));
return patches;
}
function patch(parentDom, patchObj, index = 0) {
const existing = parentDom.childNodes[index];
switch (patchObj.type) {
case "CREATE":
parentDom.appendChild(createRealNode(patchObj.newVNode));
break;
case "REMOVE":
if (existing) parentDom.removeChild(existing);
break;
case "REPLACE":
parentDom.replaceChild(createRealNode(patchObj.newVNode), existing);
break;
case "TEXT":
existing.textContent = patchObj.newVNode;
break;
case "UPDATE": {
const dom = patchObj.oldVNode.dom;
patchObj.oldVNode.newVNode = patchObj.newVNode;
patchObj.newVNode.dom = dom;
for (const { key, value } of patchObj.propPatches) {
if (value === null) dom.removeAttribute(key);
else dom.setAttribute(key, value);
}
patchObj.childPatches.forEach((childPatch, i) => patch(dom, childPatch, i));
break;
}
// "NONE": nothing changed, skip entirely — this is the whole point of diffing
}
}
// --- usage ---
let currentVNode;
function render(vnode, container) {
const patchObj = diff(currentVNode, vnode);
patch(container, currentVNode === undefined ? { type: "CREATE", newVNode: vnode } : patchObj);
currentVNode = vnode;
}
let count = 0;
function view() {
return h("div", {},
h("h1", {}, `Count: ${count}`),
h("button", { onClick: () => { count++; render(view(), document.getElementById("app")); } },
"Increment"),
);
}
render(view(), document.getElementById("app"));
Running this and clicking "Increment" repeatedly, then inspecting the DOM in
DevTools, shows the <h1>'s text node updates in place on every click while
the <button> element itself is never recreated — exactly the "diff, then
patch only what changed" behavior §33 described in the abstract.