May 19, 2024 - Póvoa de Varzim, Portugal 🇵🇹

A virtual DOM in 200 lines of JavaScript

In this post I’ll walk through the full implementation of a Virtual DOM in a bit over 200 lines of JavaScript.

The result is a full-featured and sufficiently performant virtual DOM library (demos). It’s available on NPM as the smvc package.

The main goal is to illustrate the fundamental technique behind tools like React.

React, Vue and the Elm language all simplify the creation of interactive web pages by allowing you to describe how you’d like the page to look like, and not worry about adding/removing elements to get there. They do that through a Virtual DOM.

The goal of a virtual DOM

It’s not about performance.

A Virtual DOM is an abstraction to simplify the act of modifying a UI.

You describe how you would like your page to look like and the library takes care of taking the DOM from its current state, to the state you want it to be in.

The key idea

The library will take over a single DOM element and operate inside of it.

This element should be initially empty, and we operate under the assumption that nothing other than our library will ever modify it. This will be the root of the user’s application.

If only we can modify it, then we can know exactly what is inside this element without needing to inspect it. How? By keeping track of all modifications we did to it so far.

We will track what is inside our root node by keeping a structure containing a simplified representation of each HTML element. Or more precisely, each DOM node.

Because this representation is a reflection of a DOM node, but it is not in the real DOM, let’s call it a virtual node, which will make up our virtual DOM.

The user will never create real DOM nodes, only those virtual ones. They will tell us how the entire page should look like by using virtual nodes. Then our library will take care of modifying the real DOM to make it conform to our representation of it.

To know what to modify, our library will take the virtual DOM created by the user and compare it to the virtual DOM representing how the page currently looks like. This process is called diffing. It will take note of the differences such as which elements should be added or removed and which properties should be added or removed. The output of diffing is a virtual DOM diff.

Then we will apply the changes from that diff to the real DOM. Once we are done with the modifications, the virtual DOM created by the user has now become the current faithful representation of the real DOM.

So, for the UI part of things we need to:

  1. Create a virtual representation of the DOM
  2. Diff virtual DOM nodes
  3. Apply a virtual DOM diff to an HTML element

After building that we will see how to put such virtual DOM to good use as a powerful library by adding state handling in just a few lines of code.

Representing the DOM

We want this structure to contain the minimum amount of information possible to faithfully represent what’s in the page.

A DOM node has a tag (div, p, span, etc), properties, and children. So let’s represent them using an object with these properties.

const exampleButton = {
  tag : "button",
  properties: { class: "primary", disabled: true, onClick: doSomething },
  children : [] // an array of virtual nodes
};

We also need a way to represent a text node. Text nodes don’t have tags, properties, or children. We can use an object with a single property with the textual content.

const exampleText = {
    text: "Hello World"
};

We can differentiate a text virtual node from an element one by checking if the tag or text property is present.

And that’s it! That’s our full virtual DOM already specified.

We can create some convenience functions for users to create these kind of nodes.

function h(tag, properties, children) {
    return { tag, properties, children);
}

function text(content) {
    return { text : content };
};

Now it’s easy to create complex nested structures.

const pausedScreen = h("div", {}, [
    h("h2", {}, text("Game Paused")),
    h("button", { onClick: resumeGame }, [ text("Resume") ]),
    h("button", { onClick: quitGame   }, [ text("Quit") ])
])

Diffing

Before starting the diffing, let’s think about what we want the output of the diffing operation to look like.

A diff should describe how an element is to be modified. I can think of a few types of modifications:

You might wonder why we have a replace modification in addition to the create and remove ones. This happens because unless the user gives us a unique identifier for each virtual dom node, we have no way to find out if the order of an element’s children has changed.

Consider this case where the initial dom description looked like this:

{ tag: "div",
  properties: {},
  chidlren: [
   { text: "One" },
   { text: "Two" },
   { text: "Three" }
  ]
}

And a subsequent description was like so

{ tag: "div",
  properties: {},
  chidlren: [
   { text: "Three" }
   { text: "Two" },
   { text: "One" },
  ]
}

To notice that one and three switched places we would have to compare each child of the first object to each child of the second. This cannot be done efficiently. So instead we identify elements by their index in the children array. This means that we would replace the first and last text nodes of the array.

It also means that we can only use create when we are inserting an element as the last child. So unless we are appending a child we will use replace.

Now let’s dive right in and implement this diff function.


// It takes two nodes to be compared, an old and a new one.
function diffOne(l, r) {
  // First we deal with text nodes. If their text content is not
  // identical, then let's replace the old one for the new one.
  // Otherwise it's a `noop`, which means we do nothing.
  const isText = l.text !== undefined;
  if (isText) {
    return l.text !== r.text
      ? { replace: r }
      : { noop : true };
  }

  // Next we start dealing with element nodes.
  // If the tag changed we should just replace the whole thing.
  if (l.tag !== r.tag) {
    return { replace: r };
  }

  // Now that replacement is out of the way we could only possibly
  // modify the element. So let's start by taking note of properties
  // that should be removed.
  // Any property that is not present in the new node should be removed.
  const remove = [];
  for (const prop in l.properties) {
    if (r.properties[prop] === undefined) {
      remove.push(prop);
    }
  }

  // And now let's check which ones should be set.
  // This includes new and modified properties.
  // So unless the property's value is the same in the old and
  // new nodes we will take note of it.
  const set = {};
  for (const prop in r.properties) {
    if (r.properties[prop] !== l.properties[prop]) {
      set[prop] = r.properties[prop];
    }
  }

  // Lastly we diff the list of children.
  const children = diffList(l.children, r.children);

  return { modify: { remove, set, children } };
}

As an optimisation we can notice when there are no property changes and all children modifications are noops so that we can make the element’s diff a noop too. (like this)

Diffing the list of children is straightforward enough. We create a list of diffs the size of the longest of the two lists being compared. If the old one is longer, the extra elements should be removed. If the new one is longer, the extra elements should be created. All elements in common should be diffed.

function diffList(ls, rs) {
  const length = Math.max(ls.length, rs.length);
  return Array.from({ length })
    .map((_,i) =>
      (ls[i] === undefined)
      ? { create: rs[i] }
      : (rs[i] == undefined)
      ? { remove: true }
      : diffOne(ls[i], rs[i])
    );
}

And that’s diffing done!

Applying a diff

We can already create a virtual DOM and diff it. Now it’s time to apply the diff to the real DOM.

The apply function will take as inputs a real DOM node whose children should be affected and an array of the diffs created in the previous step. The diffs of this node’s children.

apply will have no meaningful return value as its main purpose is to perform the side effect of modifying the DOM.

Its implementation is pretty simple, just dispatching the appropriate action to be performed for each child. The procedures to create and modify DOM nodes were moved to their own functions.

function apply(el, childrenDiff) {
  const children = Array.from(el.childNodes);

  childrenDiff.forEach((diff, i) => {
    const action = Object.keys(diff)[0];
    switch (action) {
      case "remove":
        children[i].remove();
        break;

      case "modify":
        modify(children[i], diff.modify);
        break;

      case "create": {
        const child = create(diff.create);
        el.appendChild(child);
        break;
      }

      case "replace": {
        const child = create(diff.replace);
        children[i].replaceWith(child);
        break;
      }

      case "noop":
        break;
    }
  });
}

Event listeners

Before tackling creation and modification, let’s think about how we would like to handle event listeners.

We want it to be very cheap and easy to add and remove event listeners, and we want to be sure that we never leave any dangling listeners around.

We will also enforce the invariant that for any given node each event should only have a single listener. This will already be the case with our API given event listeners are specified using keys in the properties object and JavaScript objects cannot have duplicate keys.

Here is an idea. We add to DOM object nodes a special property created by our library which contains an object where all the user defined event listeners for that DOM node can be found.

// Create a property `_ui` where we can store data relevant to
// our library directly in the DOM node itself.
// We store the event listeners for that node in this space.
element["_ui"] = { listeners : { click: doSomething } };

Now we can use a single function, listener, to be the event listener for all events in all nodes.

Once an event is triggered our listener function takes it and, using the listeners object, dispatches it to the appropriate user defined function to handle the event.

function listener(event) {
  const el = event.currentTarget;
  const handler = el._ui.listeners[event.type];
  handler(event);
}

So far this gives us the benefit of not needing to call addEventListener and removeEventListener every time a user listener function changes. Changing an event listener only requires changing the value in the listeners object. Later we will see a more compelling benefit of this approach.

With this knowledge we can create a dedicated function to add event listeners to DOM nodes.

function setListener(el, event, handle) {
  if (el._ui.listeners[event] === undefined) {
    el.addEventListener(event, listener);
  }

  el._ui.listeners[event] = handle;
}

One thing we haven’t done yet is to find out whether any given entry in the properties object is an event listener or not.

Let’s write a function that will tell us the name of the event to listen to or null if the property isn’t for an event listener.

function eventName(str) {
  if (str.indexOf("on") == 0) { // starts with `on`
    return str.slice(2).toLowerCase(); // lowercase name without the `on`
  }
  return null;
}

Properties

Cool, we know how to add event listeners. For attributes we could just call setAttribute, right? Well, no.

For some things we should use the setAttribute function and for others we should directly set the property in the DOM object.

For example. If you have an <input type="checkbox"> and call element.setAttribute("checked", true) on it, it will not become checked 🙃. You should instead do element["checked"] = true. And that will work.

And how do we know which to use? Well, it is complicated. I just compiled a list based on what Elm’s Html library is doing. Here is the result:

const props = new Set([ "autoplay", "checked", "checked", "contentEditable", "controls",
  "default", "hidden", "loop", "selected", "spellcheck", "value", "id", "title",
  "accessKey", "dir", "dropzone", "lang", "src", "alt", "preload", "poster",
  "kind", "label", "srclang", "sandbox", "srcdoc", "type", "value", "accept",
  "placeholder", "acceptCharset", "action", "autocomplete", "enctype", "method",
  "name", "pattern", "htmlFor", "max", "min", "step", "wrap", "useMap", "shape",
  "coords", "align", "cite", "href", "target", "download", "download",
  "hreflang", "ping", "start", "headers", "scope", "span" ]);

function setProperty(prop, value, el) {
  if (props.has(prop)) {
    el[prop] = value;
  } else {
    el.setAttribute(prop, value);
  }
}

Creating and modifying

With all of that in our hands we can now have a go at creating a real DOM node from a virtual DOM one.

function create(vnode) {
   // Create a text node
  if (vnode.text !== undefined) {
    const el = document.createTextNode(vnode.text);
    return el;
  }

  // Create the DOM element with the correct tag and
  // already add our object of listeners to it.
  const el = document.createElement(vnode.tag);
  el._ui = { listeners : {} };

  for (const prop in vnode.properties) {
    const event = eventName(prop);
    const value = vnode.properties[prop];
    // If it's an event set it otherwise set the value as a property.
    (event !== null)
      ? setListener(el, event, value)
      : setProperty(prop, value, el);
  }

  // Recursively create all the children and append one by one.
  for (const childVNode of vnode.children) {
    const child = create(childVNode);
    el.appendChild(child);
  }

  return el;
}

The modify function is similarly straightforward. It sets and removes the approriate properties of the node and hands control to the apply function for it to change the children. Notice the corecursion between modify and apply.

function modify(el, diff) {
  // Remove props
  for (const prop of diff.remove) {
    const event = eventName(prop);
    if (event === null) {
      el.removeAttribute(prop);
    } else {
      el._ui.listeners[event] = undefined;
      el.removeEventListener(event, listener);
    }
  }

  // Set props
  for (const prop in diff.set) {
    const value = diff.set[prop];
    const event = eventName(prop);
    (event !== null)
      ? setListener(el, event, value)
      : setProperty(prop, value, el);
  }

  // Deal with the children
  apply(el, diff.children);
}

Handling state

We now have a full virtual DOM rendering implementation. Using h and text we can create a VDOM and using apply and diffList we can materialise it into the real DOM and update it.

We could stop here, but I think the implementation is not complete without a structured way to handle state changes. Aterall the whole point of a virtual DOM is that you repeatedly recreate it when your state changes.

The API

We will implement a very straightforward way to go about it. There will be two types of user-defined values:

We will ask the user to implement two functions:

This is enough to build any complex application.

The user provides these two functions at the start of the program and the VDOM library will control when they are called. The user never calls them directly.

We also need to give users a way to emit messages to be handled via the update function. We will do that by providing an enqueue function, which adds a message to a queue of messages to be dealt with.

The final bits we need from the user are an initial state to get started with and an HTML node inside of which the VDOM should be rendered.

And with these final pieces we have our complete API. We define a function called init which will take all the required input from the user and get the application started. It will return the enqueue function for that application. This design allows us to have multiple VDOM applications running in the same page and each will have its own enqueue function.

Here is a counter implemented with this design:

function view(state) {
    return [
        h("p", {}, [ text(`Counter: ${state.counter}`) ])
    ];
}

function update(state, msg) {
    return { counter : state.counter + msg }
}

const initialState = { counter: 0 };

const root = document.querySelector(".my-application");

// Start application
const { enqueue } = init(root, initialState, update, view);

// Increase the counter by one every second.
setInterval(() => enqueue(1), 1000);

Init function

With the API fleshed out, let’s think about how this init function should work.

We will definitely call update once for every message. But we don’t need to call view every time the state changes as that might cause us to update the DOM more often than the browser is able to display DOM updates. We want to call view at most once per animation frame.

Also, we want users to be able to call enqueue as many times as they want and from wherever they want without it causing our application to break. This means that we should accept enqueue to be called even from within the update function.

We will do that by decoupling message queuing, updating the state, and updating the DOM.

Calls to enqueue will just add the message to an array. Then, on every animation frame we will take all queued messages and process them by calling update on each. Once all messages have been processed we will render the resulting state using the view function.

Running the application now consists of just repeating this process on every animation frame.

// Start managing the contents of an HTML element.
function init(root, initialState, update, view) {
  let state = initialState; // client application state
  let nodes = []; // virtual DOM nodes
  let queue = []; // msg queue

  function enqueue(msg) {
    queue.push(msg);
  }

  // draws the current state
  function draw() {
    let newNodes = view(state);
    apply(root, diffList(nodes, newNodes));
    nodes = newNodes;
  }

  function updateState() {
    if (queue.length > 0) {
      let msgs = queue;
      // replace queue with an empty array so that we don't process
      // newly queued messages on this round.
      queue = [];

      for (msg of msgs) {
        state = update(state, msg);
      }

      draw();
    }

    // schedule next round of state updates
    window.requestAnimationFrame(updateState);
  }

  draw();         // draw initial state
  updateState();  // kick-off state update cycle

  return { enqueue };
}

Convenience

Our users can call enqueue from anywhere they want, but currently it is a bit cumbersome to call it from within the update and view functions. This is so because enqueue is returned by init, which expects update and view to already be defined.

Let’s first improve that by passing enqueue as the third argument to update. Now our state update looks like this:

state = update(state, msg, enqueue)

Easy enough. Now let’s think about how to improve the situation in the view function.

Users won’t be calling enqueue during rendering. They will call it in response to some event like onClick or onInput. So it makes sense for the user-created handling function for those events to receive enqueue as an argument, together with the event object.

With that, event handling could look like this:

const button = h(
    "button",
    { onClick : (_event, enqueue) => { enqueue(1) } },
    [text("Increase counter")]
);

We could make it even easier by making any value returned by the event handler that is different from undefined to be treated as a message. That would allow the button above to be written as:

const button = h(
    "button",
    { onClick : () => 1 },
    [text("Increase counter")]
);

Cool, how can we implement that? Our single listener function which dispatches the events will need access to enqueue. The easiest way to pass it is through the _ui object which already holds the user-defined listeners.

With that, our listener implementation becomes:

function listener(event) {
  const el = event.currentTarget;
  const handler = el._ui.listeners[event.type];
  const enqueue = el._ui.enqueue;
  const msg = handler(event);
  if (msg !== undefined) {
    enqueue(msg);
  }
}

To add enqueue to _ui at node creation time we will need to pass it through apply modify and create.

function apply(el, enqueue, childrenDiff) { ... }
function modify(el, enqueue, diff) { ... }
function create(enqueue, vnode) { ... }

With that in place, our full library is now complete! You can see the full code here.

Demos

Todo MVC

Below is an implementation of the famous TodoMVC app running on the library we just wrote above. Full source.

1 Million nodes

For something that pushed the limits a bit here is a link to a page rendering a virtualised list containing 1 million HTML elements and updating at hopefully 60 frames per second. Full Source