Let's build a Virtual DOM in JS from scratch

Approx Time: 11 Minutes

Rishabh Pandey • September 15, 2024

vue js

A while ago, I wanted to really understand how modern UI libraries like Vue manage efficient rendering. So, I decided to challenge myself by creating my own Virtual DOM library. In this article, we will see how to do build our own Virtual DOM and use it to create a basic Todo list app.

Creating the Virtual DOM Library

Step 1: Setting Up the Project

First, I created a new directory for my library:

mkdir mini-vdom
cd mini-vdom
npm init -y

This initializes a new npm package with default settings.

Step 2: Implementing the Virtual DOM

I wanted the library to be minimal yet functional. Here's how I structured it.

1. Defining Virtual Nodes (VNodes)

Why do we need to define Virtual Nodes (VNodes)?

Manipulating the actual DOM directly for every little change is a recipe for poor performance. The DOM is slow, and updating it frequently can make the UI laggy, which is not what we want.

We need a way to represent the UI components in a lightweight, efficient manner before committing any changes to the real DOM. That's where Virtual Nodes, or VNodes, come into play.

By defining VNodes, we're essentially creating plain JavaScript objects that mirror the structure of actual DOM elements. These VNodes contain information about the element type, its properties (like attributes and event listeners), and its children.

// src/h.js
function h(type, props, ...children) {
  return { type, props: props || {}, children };
}

module.exports = { h };

2. Rendering VNodes to the Real DOM

After setting up our VNodes, the next challenge was figuring out how to turn these virtual nodes into actual DOM elements that the browser can display. Since VNodes are just JavaScript objects representing the structure of my UI, we need a way to translate them into real DOM nodes.

// src/render.js
function render(vNode) {
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }

  const $el = document.createElement(vNode.type);

  // Set properties
  for (const [key, value] of Object.entries(vNode.props)) {
    if (key.startsWith('on')) {
      $el.addEventListener(key.substring(2).toLowerCase(), value);
    } else {
      $el.setAttribute(key, value);
    }
  }

  // Render and append children
  vNode.children
    .map(render)
    .forEach(child => $el.appendChild(child));

  return $el;
}

module.exports = { render };

3. Implementing the Diffing Algorithm

After getting the VNodes rendering on the page, the next big challenge is efficiently updating the DOM when the application state changes. I didn't want to re-render the entire UI every time something changed—that would be inefficient and could cause performance issues.

This is where the diffing algorithm comes into play. The idea is to compare the new Virtual DOM tree with the previous one, figure out what has changed, and update only those parts in the real DOM. The diffing algorithm helps us:

// src/diff.js
function diff(oldVNode, newVNode) {
  if (newVNode === undefined) {
    return ($node) => {
      $node.remove();
      return undefined;
    };
  }

  if (typeof oldVNode === 'string' || typeof newVNode === 'string') {
    if (oldVNode !== newVNode) {
      return ($node) => {
        const $newNode = render(newVNode);
        $node.replaceWith($newNode);
        return $newNode;
      };
    } else {
      return ($node) => $node;
    }
  }

  if (oldVNode.type !== newVNode.type) {
    return ($node) => {
      const $newNode = render(newVNode);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  const patchProps = diffProps(oldVNode.props, newVNode.props);
  const patchChildren = diffChildren(oldVNode.children, newVNode.children);

  return ($node) => {
    patchProps($node);
    patchChildren($node);
    return $node;
  };
}

function diffProps(oldProps, newProps) {
  const patches = [];

  // Set new or changed props
  for (const [key, value] of Object.entries(newProps)) {
    patches.push(($node) => {
      $node.setAttribute(key, value);
      return $node;
    });
  }

  // Remove old props
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patches.push(($node) => {
        $node.removeAttribute(key);
        return $node;
      });
    }
  }

  return ($node) => {
    for (const patch of patches) {
      patch($node);
    }
  };
}

function diffChildren(oldVChildren, newVChildren) {
  const childPatches = [];
  oldVChildren.forEach((oldChild, i) => {
    childPatches.push(diff(oldChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push(($node) => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }

  return ($parent) => {
    $parent.childNodes.forEach(($child, i) => {
      childPatches[i]($child);
    });

    for (const patch of additionalPatches) {
      patch($parent);
    }

    return $parent;
  };
}

module.exports = { diff };

There are 4 parts the diff process.

1. The diff function

I started by writing a diff function that takes two VNodes—the old one and the new one—and returns a function (which I call a "patch") that can update the real DOM accordingly. Let me break down what this function does:

2. Diffing Props
3. Diffing Children

Children are a bit trickier since they are arrays of VNodes. We need to:

4. Applying the Patches

4. Putting It All Together

Finally, we export all the functions:

// index.js
const { h } = require('./src/h');
const { render } = require('./src/render');
const { diff } = require('./src/diff');

module.exports = { h, render, diff };

Step 3: Preparing for Packaging

I updated the package.json to include the main entry point:

{
  "name": "mini-vdom",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

Step 4: Publishing Locally

For testing purposes, I used npm link to make this package available globally:

npm link

Using the Virtual DOM Library in a Project

Now, let's create a new project and use our mini-vdom library to render a Todo List.

Step 1: Setting Up the Todo List Project

mkdir todo-app
cd todo-app
npm init -y

Step 2: Installing the mini-vdom Package

Since we used npm link, we can link it in our project:

npm link mini-vdom

Alternatively, if you publish it to npm, you can install it via:

npm install mini-vdom

Step 3: Creating the Application Files

1. HTML File

Create an index.html file:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Todo List</title>
</head>
<body>
  <div id="app"></div>
  <script src="app.js"></script>
</body>
</html>

2. JavaScript File

Create an app.js file:

// app.js
const { h, render, diff } = require('mini-vdom');

let todos = [];
let filter = 'all';

function view(todos, filter) {
  const filteredTodos = todos.filter(todo => {
    if (filter === 'all') return true;
    return filter === 'completed' ? todo.completed : !todo.completed;
  });

  return h('div', null,
    h('h1', null, 'Todo List'),
    h('input', { type: 'text', id: 'new-todo', placeholder: 'What needs to be done?' }),
    h('button', { onclick: addTodo }, 'Add'),
    h('ul', null, ...filteredTodos.map(todoItem)),
    h('div', null,
      h('button', { onclick: () => setFilter('all') }, 'All'),
      h('button', { onclick: () => setFilter('active') }, 'Active'),
      h('button', { onclick: () => setFilter('completed') }, 'Completed'),
    )
  );
}

function todoItem(todo, index) {
  return h('li', null,
    h('input', {
      type: 'checkbox',
      checked: todo.completed,
      onchange: () => toggleTodo(index)
    }),
    h('span', null, todo.text),
    h('button', { onclick: () => removeTodo(index) }, 'Delete')
  );
}

function addTodo() {
  const input = document.getElementById('new-todo');
  if (input.value.trim() === '') return;
  todos.push({ text: input.value.trim(), completed: false });
  input.value = '';
  update();
}

function toggleTodo(index) {
  todos[index].completed = !todos[index].completed;
  update();
}

function removeTodo(index) {
  todos.splice(index, 1);
  update();
}

function setFilter(value) {
  filter = value;
  update();
}

let vNode = view(todos, filter);
let $rootEl = render(vNode);
const $app = document.getElementById('app');
$app.appendChild($rootEl);

function update() {
  const newVNode = view(todos, filter);
  const patches = diff(vNode, newVNode);
  $rootEl = patches($rootEl);
  vNode = newVNode;
}

Step 4: Setting Up a Development Server

To serve the application, I used a simple static server. You can install one globally:

npm install -g serve

Run the server:

serve .

This will start a server at http://localhost:5000 (or another port if 5000 is in use).


Further Enhancements

There are several ways to improve this library:


Building this Virtual DOM library and using it in a real project was a fantastic learning experience. It gave me deeper insights into how libraries like React manage efficient updates and state management.

Share on Twitter