DEV Community

Cover image for Learn JavaScript by building a UI framework: Part 6 - Intro to Virtual DOM Algorithms
Carl Mungazi
Carl Mungazi

Posted on

Learn JavaScript by building a UI framework: Part 6 - Intro to Virtual DOM Algorithms

This article is the 6th in a series of deep dives into JavaScript. You can view previous articles by visiting the Github repository associated with this project.

This series does not comprehensively cover every JavaScript feature. Instead, features are covered as they crop up in solutions to various problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.


In the last article we extended Aprender's functionality by adding events to DOM elements. In this post, we will take the first step in tackling the meatiest part of a UI framework - dynamically changing DOM elements.

As always, we will begin with some example code. Given the two objects below which represent a UI, how do we change from the old to the new?

{ // old dom
  type: "div",
  attrs: {},
  children: [
    "Search",
    {
      type: "p",
      attrs: {},
      children: []
    }
  ]
}

{ // new dom
  type: "div",
  attrs: {},
  children: [
    "No Search",
    {
      type: "span",
      attrs: {},
      children: []
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At the heart of any UI framework (or secret sauce, if you like) is the way changes are detected and made. In a typical application, the underlying framework devotes a lot of time to figuring out what changed, how it changed and how the change needs to be applied. This issue on the React repository, for instance, is a detailed technical discussion about the matter. There are many virtual DOM implementations out there and to construct our own we will look to simple-virtual-dom for inspiration.

So...what changed?

The "what changed?" part of the implementation begins with the following function:

function diff(oldTree, newTree) {
  const patches = {};
  const index = 0;

  performDiff(oldTree, newTree, patches, index)

  return patches;
}
Enter fullscreen mode Exit fullscreen mode

The arguments oldTree and newTree are object representations of the UI - the old state and the new state. In our case, we are changing the text from "Search" to "No Search" and the paragraph element to a span element. The patches object stores the differences between both states and it will be populated when we find the changes using performDiff. We will be making recursive calls as part of the diffing process so index acts as a counter to keep track of the current diff iteration. Finally, we return the patches object. It will be used later to make the changes. Let us look at performDiff:

function performDiff(oldTree, newTree, patches, index) {
  const currentPatch = [];

  if (newTree === undefined) {
    // we do nothing here because the final else statement will deal with it
  } else if (typeof oldTree === 'string' && typeof newTree === 'string') {  
    if (oldTree !== newTree) {
      currentPatch.push({
        type: 'TEXT',
        content: newTree
      })
    }
  } else if (oldTree.type === newTree.type) {
    diffChildren(oldTree.children, newTree.children, patches, index)
  } else {
    currentPatch.push({
      type: 'REPLACE',
      node: newTree
    })
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
}
Enter fullscreen mode Exit fullscreen mode

performDiff is called recursively when we diff through any children, so currentPatch holds the changes belonging to the current iteration. The bulk of this function's work is done by a series of if statements which stem from the following questions:

Do we have a new DOM tree/element to diff against?

If we do not, we do nothing because the else clause of our if statement will handle that.

Are we comparing text nodes?

If we are dealing with text nodes, we will only make changes if the text is different. This change is recorded via an object that holds information about the type of change and the content related to that change.

Do we need to compare the children?

This is where the recursive fun begins. The diffChildren function is:

function diffChildren(oldChildren, newChildren, patches, index) {
  oldChildren.forEach((oldChild, idx) => {
    index++
    performDiff(oldChild, newChildren[idx], patches, index)
  })
}
Enter fullscreen mode Exit fullscreen mode

It is very basic. Other frameworks shine here because they have to consider a host of issues. For instance, if none of the children have changed, is there any way of skipping this call? What is the most efficient way of comparing the child elements?

Are we comparing different elements?

If we are diffing two completely different DOM elements, the simplest thing to do is replace the old one with the new.

The algorithms of most UI frameworks can be boiled down to a similar list of questions.


In our case, the example code will go down the child comparison route, so let us walk through that:

First, diffChildren receives an array of children from the elements we are comparing. For each child, it recursively calls performDiff. However, before doing so it increments the index counter. In our example it goes from 0 to 1.

We call performDiff with the arguments "Search" and "No Search" as our comparison elements. As we are comparing text nodes, we create this object:

{
  type: "TEXT",
  content: "No Search"
}
Enter fullscreen mode Exit fullscreen mode

and store it in the currentPatch array that is initialised whenever performDiff is invoked. This array keeps track of any changes that need to be made and if there are, at the end of the function they are assigned to the patches object with index being used as the key.

We repeat this process for the second child and once performDiff has completed its work, it leaves us with the following object:

{
  1: [
    {
      type: "TEXT",
      content: "No Search"
    }
  ],
  2: [
    {
      type: "REPLACE",
      node: {
        type: "span",
        attrs: {},
        children: []
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This object, returned by the diff function, represents the changes we want to make to our UI. You can think of this as the first phase of our update process. In the second phase we will apply these changes to the DOM. This two-step process is similar to how React does things.

We will begin applying our changes with these two functions:

function patch(rootDomNode, patches) {
  const index = 0;
  performPatches(rootDomNode, patches, index)
}

function performPatches(node, patches, index) {
  const currentPatches = patches[index];

  if (node.childNodes) {
    node.childNodes.forEach(node => {
      index++
      performPatches(node, patches, index)
    });
  }

  if (currentPatches) {
    applyPatches(node, currentPatches)
  }
}
Enter fullscreen mode Exit fullscreen mode

The workflow here should be familiar. patch takes the DOM element which is being updated and then calls performPatches with the changes and a counter. In performPatches we first perform any changes on child elements before making changes on the target element.

function applyPatches(node, currentPatches) {
  currentPatches.forEach(patch => {
    switch (patch.type) {
      case 'TEXT': {
        if (node.textContent) {
          node.textContent = patch.content
        }
        break;
      }
      case 'REPLACE': {
        const newNode = render(patch.node);
        node.parentNode.replaceChild(newNode, node);
        break;
      }
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

The humble switch case statement is at the core of the second phase of our update process. When replacing elements, we call upon Aprender's render function to create the DOM element for us.

And that is it! With the diff and patch functions we can now update DOM elements. If we were to write this as a proper application, it would be something like:

const aprender = require('aprender');

const oldTree = aprender.createElement('div', { 
    children: ['Search', aprender.createElement('p')] 
  }
);

const newTree = aprender.createElement('div', { 
  children: ['No Search', aprender.createElement('span')] 
  }
);

const root = aprender.render(oldTree)
aprender.mount(root, document.getElementById('app'))

const diff = aprender.diff(oldTree, newTree);

setTimeout(() => {
  aprender.patch(root, diff);
}, 5000)
Enter fullscreen mode Exit fullscreen mode

 Summary

Our algorithm is nothing more than a series of conditional checks. If you look at the heart of an UI framwork's update mechanism, you will find the same. However, our implementation is still missing a fundamental piece - the ability to perform updates automatically and in response to data changes. We have to use setTimeout to trigger the change detection process. We will fix that in the next post.

Top comments (2)

Collapse
 
diegotc86 profile image
Diego Torres • Edited

Hi Carl! Thanks for the amazing job with this series. Any plan on releasing part 7 any time soon?

Collapse
 
carlmungazi profile image
Carl Mungazi

Hey Diego, thanks for the feedback! I am hoping to carve out some time and release more posts.