Frontend Labs
⋯

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.


Generated from real-life-JS-guide.md · 8 Oct 2026 16:36