đź’Ż hundred VDom

Hundred is an intentionally simple 🌲 virtual DOM implementation which is helpful to look through to get a basic working idea of how this all works. It consists of only 63LOC. Here they are, broken up in a literate style, with some annotations to explain what’s going on.

First some basic types for the implementation:

src/index.ts:L1-L7

type Props = Record<string, unknown>;
interface VElement {
  tag: string;
  props: Props;
  children: VNode[];
}
type VNode = VElement | string;

So here a node for the virtual DOM (a VNode) can be either a VElement or just a string. A VNode can have children, and thus we can have a tree of virtual DOM nodes rooted with a particular node.

The first function is the h function, which takes a few arguments and created a VElement object. This function has a call signature corresponding to the convention for node creation functions in 👩‍🔬 JSX:

src/index.ts:L9-L13

export const h = (tag: string, props: Props = {}, children: VNode[] = []): VElement => ({
  tag,
  props,
  children,
});

Basically this just takes a tag (like "div" or whatever), some props, and some children and then creates a VElement with those things.

Next up is createElement, which takes a VNode (so either a VElement or a string) and creates a corresponding DOM node (either an HTMLElement or a Text node):

src/index.ts:L15-L29

export const createElement = (vnode: VNode): HTMLElement | Text => {
  if (typeof vnode === 'string') return document.createTextNode(vnode);

  const el = document.createElement(vnode.tag);

  for (const prop in vnode.props) {
    el[prop] = vnode.props[prop];
  }

  for (const child of vnode.children) {
    el.appendChild(createElement(child));
  }

  return el;
};

Basically if the vnode argument is a string, then we’ll just create and return a Text node for it. Otherwise, we’ll

  1. call document.createElement with the tag set on the vnode
  2. iterate through props and set then on that new element
  3. iterate through the VNode children and recur, using .appendChild to append the return value to the node we just created and
  4. finally, return the newly created DOM tree

If we hand this function a tree of virtual DOM nodes we’ll get back a corresponding tree of DOM nodes. Note that this doesn’t add these nodes to the document yet, but just creates and then returns them.

The final function patch is the real meat of the whole virtual DOM. This is where we diff the old and new VNode trees, figuring out when we need to make updates to the concrete DOM, and so on. This is a recursive function (hooray tree traversal) which will produce a new concrete DOM tree which reflects the newVNode virtual DOM tree and is rooted in the el parameter.

Note when reading through that this patch function does eager-updates, so as it traverses the virtual DOM it will make updates to the concrete DOM synchronously and immediately as it discovers the need for them. If a virtual DOM implementation is used only to render DOM elements and there are no components with lifecycles and so on then this will work just fine, but a more complicated component lifecycle does require some sort of batching in order to provide guarantees about when various lifecycle events take place.

src/index.ts:L31-L63

export const patch = (el: HTMLElement | Text, newVNode?: VNode, oldVNode?: VNode): void => {
  // if no new VNode, remove the HTMLElement
  if (!newVNode && newVNode !== '') return el.remove();
  if (typeof oldVNode === 'string' || typeof newVNode === 'string') {
    // if these are both strings but `old !== new` then create an element with the
    // new and replace the old with that
    // `el.replaceWith` replaces `el` in place
    if (oldVNode !== newVNode) return el.replaceWith(createElement(newVNode));
  } else {
    // not a string
    if (oldVNode?.tag !== newVNode?.tag) {
      // we only need to create a new DOM Element if the tag is different
      // otherwise, we can just re-use it
      return el.replaceWith(createElement(newVNode));
    }

    // splat together old and new props
    for (const prop in { ...oldVNode.props, ...newVNode.props }) {
      if (newVNode.props[prop] === undefined) {
        // if a prop is undefined on the new node delete it from the el
        delete el[prop];
      } else if (
        oldVNode.props[prop] === undefined ||
        oldVNode.props[prop] !== newVNode.props[prop]
      ) {
        // in any case where the newVNode props doesn't equal the
        // old value we want to set the value onto the el
        el[prop] = newVNode.props[prop];
      }
    }

    // iterate through the old children in reverse order, attempting to match
    // them up with corresponding new children.
    for (let i = (oldVNode.children?.length ?? 0) - 1; i >= 0; --i) {
      patch(
        <HTMLElement | Text>el.childNodes[i],
        (newVNode.children || [])[i],
        oldVNode.children[i],
      );
    }
    // we've already handled the first `oldVNode.children.length` nodes of
    // `newVNode.children` so we should start at that index in case `newVNode.children`
    // is longer than `oldVNode.children`.
    for (let i = oldVNode.children?.length ?? 0; i < newVNode.children?.length ?? 0; i++) {
      el.appendChild(createElement(newVNode.children[i]));
    }
  }
};

I think this is a particularly simple and straightforward virtual DOM implementation. It’s very few lines of code, and quite a bit less complicated than other examples, in particular because it has a simple, eager-update patch implementation which doesn’t bother with any change batching, with a more complicated algorithm for matching up children between renders, or anything like that. But it definitely conveys the basic ideas of how this all works.

From the project’s README they give this example of using this to render a div with “Hello World!” in it:

import { h, createElement, patch } from 'hundred';

const el = createElement(h('div'));

patch(el, h('div', null, 'Hello World!'), h('div'));