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:
type Props = Record<string, unknown>;
interface VElement {
: string;
tag: Props;
props: VNode[];
children
}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:
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):
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) {
= vnode.props[prop];
el[prop]
}
for (const child of vnode.children) {
.appendChild(createElement(child));
el
}
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
document.createElement
with the tag set on the
vnodeVNode
children and recur, using
.appendChild
to append the return value to the node we just
created andIf 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.
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 (
} .props[prop] === undefined ||
oldVNode.props[prop] !== newVNode.props[prop]
oldVNode
) {// in any case where the newVNode props doesn't equal the
// old value we want to set the value onto the el
= newVNode.props[prop];
el[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],
.children || [])[i],
(newVNode.children[i],
oldVNode;
)
}// 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++) {
.appendChild(createElement(newVNode.children[i]));
el
}
}; }
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'));