Skip to main content Accessibility Feedback

DOM diffing with vanilla JS: part 1

Last week, I started a series on how Reef, my 2.5kb alternative to React and Vue, works under-the-hood.

First, we learned how to convert markup strings into real HTML elements. Then, we learned how to create a map of the DOM tree.

Today, we’re going to learn how to put them both together to diff the DOM and selectively update just the things that need changing.

Quick head up: this is a bit more complex than the kind of things I normally write about. As a result, this article is both longer than usual, and is split into two parts. The second part in the series drops tomorrow.

What is DOM diffing?

If you’re not familiar with the concept already, DOM diffing is the process of comparing the existing UI to the UI you want and identifying what needs to change to get there.

For example, let’s say you’re working on a todo list app, and the existing UI looks like this.

<div id="app">

	<h1>Starting at Hogwarts</h1>

	<p><em>You don't have any todo items yet.</em></p>

</div>

And you wanted the markup to look like this.

<div id="app">

	<h1>Starting at Hogwarts</h1>

	<ul>
		<li>
			<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 800 800">
				<title>Completed</title>
				<path d="M0 0v800h800V0H0zm750 750H50V50h700v700z"/>
			</svg>
			Fix my wand
		</li>
		<li>
			<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 800 800">
				<title>Incomplete</title>
				<path d="M0 0v800h800V0H0zm750 750H50V50h700v700z"/>
				<path d="M125 400l75-75 125 125 275-275 75 75-350 350z"/>
			</svg>
			Buy new robes
		</li>
		<li>
			<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 800 800">
				<title>Incomplete</title>
				<path d="M0 0v800h800V0H0zm750 750H50V50h700v700z"/>
				<path d="M125 400l75-75 125 125 275-275 75 75-350 350z"/>
			</svg>
			Enroll in courses
		</li>
	</ul>

</div>

In diffing the DOM, your script should identify that the h1 element is identical and doesn’t need any changes.

It should detect that the p element is gone and should be removed, and that a ul element with a variety of child elements and text nodes need to be created.

Let’s look at how we’d do that.

Getting DOM tree maps

Before we do anything else, we’ll need to get DOM tree maps for the existing UI and the desired UI.

We’ll need to run our template’s markup string through the stringToHTML() method we discussed last week. Then, we’ll pass it an the #app element through the createDOMMap() mapping function.

// The desired UI
var template = '<h1>Starting at Hogwarts</h1> ...';

// Get the existing UI node
var app = document.querySelector('#app');

// Get DOM maps
var templateMap = createDOMMap(stringToHTML(template));
var domMap = createDOMMap(app);

Now we’re ready to start diffing.

Creating a diffing function

Let’s start by creating a diffing helper function.

We’ll pass in three arguments: the DOM tree map for the template, the DOM tree map for the existing UI, and the element the DOM tree map in the existing UI belongs to (in the example above, that would be the #app element).

/**
 * Diff the existing DOM node versus the template
 * @param  {Array} templateMap A DOM tree map of the template content
 * @param  {Array} domMap      A DOM tree map of the existing DOM node
 * @param  {Node}  elem        The element to render content into
 */
var diff = function (templateMap, domMap, elem) {
	// code goes here...
};

We’ll use it like this.

// The desired UI
var template = '<h1>Starting at Hogwarts</h1> ...';

// Get the existing UI node
var app = document.querySelector('#app');

// Get DOM maps
var templateMap = createDOMMap(stringToHTML(template));
var domMap = createDOMMap(app);

// Diff the DOM
diff(templateMap, domMap, app);

Removing extra elements

The first thing we’re going to do is remove excess elements from the DOM. For example, imagine if the todo list had five items and we needed to reduce it to three.

We’ll get the length of our domMap array, and subtract it from the length of our templateMap. If the number is greater than 0—if the DOM has more elements than the desired UI—we’ll remove some.

var diff = function (templateMap, domMap, elem) {

	// If extra elements in domMap, remove them
	var count = domMap.length - templateMap.length;
	if (count > 0) {
		// Remove the extra nodes
	}

};

We can do this with a for loop.

Instead of starting at 0 and working our way up, we’ll use it to loop backwards and decrease our count by 1. As long as count is higher than 0, we’ll keep going.

Inside the loop, we’ll use the removeChild() method to remove the element (which you may recall we cached to the node property).

var diff = function (templateMap, domMap, elem) {

	// If extra elements in domMap, remove them
	var count = domMap.length - templateMap.length;
	if (count > 0) {
		for (; count > 0; count--) {
			domMap[domMap.length - count].node.parentNode.removeChild(domMap[domMap.length - count].node);
		}
	}

};

Diff each element

Now that we’ve removed the extra elements, let’s loop through each item in our templateMap and compare it to the corresponding element in the domMap.

We’ll use the Array.forEach() method for this. We’ll compare the current item to the item at the same index in the domMap array.

var diff = function (templateMap, domMap, elem) {

	// If extra elements in domMap, remove them
	var count = domMap.length - templateMap.length;
	if (count > 0) {
		for (; count > 0; count--) {
			domMap[domMap.length - count].node.parentNode.removeChild(domMap[domMap.length - count].node);
		}
	}

	// Diff each item in the templateMap
	templateMap.forEach(function (node, index) {
		// Diff all the things!
	});

};

Creating new elements

If the element doesn’t exist in the domMap, we’ll need to create it.

Let’s create a makeElem() helper function to create the actual element for us. We’ll pass in the node details from our templateMap as an argument.

/**
 * Make an HTML element
 * @param  {Object} elem The element details
 * @return {Node}        The HTML element
 */
var makeElem = function (elem) {
	// Code goes here...
};

If the element type is text, we’ll use the createTextNode() method to create a text node with the content property as its value. If it’s comment, we’ll use the createComment() method to create a comment element.

You may recall in the DOM map article I mentioned that we need to handle SVGs a little differently. If our element has the isSVG property, we’ll use the createEelementNS to create an SVG.

For any other type of element, we’ll use the createElement() method to create an element.

var makeElem = function (elem) {

	// Create the element
	var node;
	if (elem.type === 'text') {
		node = document.createTextNode(elem.content);
	} else if (elem.type === 'comment') {
		node = document.createComment(elem.content);
	} else if (elem.isSVG) {
		node = document.createElementNS('http://www.w3.org/2000/svg', elem.type);
	} else {
		node = document.createElement(elem.type);
	}

};

Next, we need to add any required attributes to our element. To keep our function more readable, we’ll create a helper function for that, and pass the newly created node and the elem.attributes into it as arguments.

We’ll look at how this function works in just a minute.

var makeElem = function (elem) {

	// Create the element
	var node;
	if (elem.type === 'text') {
		node = document.createTextNode(elem.content);
	} else if (elem.type === 'comment') {
		node = document.createComment(elem.content);
	} else if (elem.isSVG) {
		node = document.createElementNS('http://www.w3.org/2000/svg', elem.type);
	} else {
		node = document.createElement(elem.type);
	}

	// Add attributes
	addAttributes(node, elem.atts);

};

Then, we’ll return our newly created node.

var makeElem = function (elem) {

	// Create the element
	var node;
	if (elem.type === 'text') {
		node = document.createTextNode(elem.content);
	} else if (elem.type === 'comment') {
		node = document.createComment(elem.content);
	} else if (elem.isSVG) {
		node = document.createElementNS('http://www.w3.org/2000/svg', elem.type);
	} else {
		node = document.createElement(elem.type);
	}

	// Add attributes
	addAttributes(node, elem.atts);

	return node;

};

Handling child nodes

If the element has any child nodes—if the length of the elem.children property is greater than 0—we’ll look through each one and recursively pass it into the makeElem() method.

Then, we’ll use the appendChild() method to inject it into the newly created node.

var makeElem = function (elem) {

	// Create the element
	var node;
	if (elem.type === 'text') {
		node = document.createTextNode(elem.content);
	} else if (elem.type === 'comment') {
		node = document.createComment(elem.content);
	} else if (elem.isSVG) {
		node = document.createElementNS('http://www.w3.org/2000/svg', elem.type);
	} else {
		node = document.createElement(elem.type);
	}

	// Add attributes
	addAttributes(node, elem.atts);

	// If the element has child nodes, create them
	// Otherwise, add textContent
	if (elem.children.length > 0) {
		elem.children.forEach(function (childElem) {
			node.appendChild(makeElem(childElem));
		});
	}

	return node;

};

Adding content

If there are no child elements, and if our element isn’t a text node, we’ll use the textContent property to add the content property value to our node.

var makeElem = function (elem) {

	// Create the element
	var node;
	if (elem.type === 'text') {
		node = document.createTextNode(elem.content);
	} else if (elem.type === 'comment') {
		node = document.createComment(elem.content);
	} else if (elem.isSVG) {
		node = document.createElementNS('http://www.w3.org/2000/svg', elem.type);
	} else {
		node = document.createElement(elem.type);
	}

	// Add attributes
	addAttributes(node, elem.atts);

	// If the element has child nodes, create them
	// Otherwise, add textContent
	if (elem.children.length > 0) {
		elem.children.forEach(function (childElem) {
			node.appendChild(makeElem(childElem));
		});
	} else if (elem.type !== 'text') {
		node.textContent = elem.content;
	}

	return node;

};

Adding attributes

Now lets look at how to add attributes to the element we created in our makeElem() function.

Our addAttributes() helper function will accept two arguments: the element, and an array of attributes.

/**
 * Add attributes to an element
 * @param {Node}  elem The element
 * @param {Array} atts The attributes to add
 */
var addAttributes = function (elem, atts) {
	// Code goes here...
};

We’ll use the Array.forEach() method to loop through each attribute and add it to the element.

The easiest way to add each attribute is with the setAttribute() method.

var addAttributes = function (elem, atts) {
	atts.forEach(function (attribute) {
		elem.setAttribute(attribute.att, attribute.value);
	});
};

Handling attributes without a value

Certain attributes, like hidden or required or checked, don’t need a value to work on an element.

<!-- This is valid -->
<input type="checkbox" checked>

The attribute object for that element would look like this:

var attribute = {
	att: 'checked',
	value: null
};

But setAttribute() requires a second argument to work in certain browsers (like Firefox). We’ll add a conditional true for the second argument if no value is present, to make sure this works everywhere.

var addAttributes = function (elem, atts) {
	atts.forEach(function (attribute) {
		elem.setAttribute(attribute.att, attribute.value || true);
	});
};

Handling classes

The setAttribute() method won’t work for classes. If the att property is class, we’ll use the className property instead.

var addAttributes = function (elem, atts) {
	atts.forEach(function (attribute) {
		// If the attribute is a class, use className
		// Otherwise, set the attribute
		if (attribute.att === 'class') {
			elem.className = attribute.value;
		} else {
			elem.setAttribute(attribute.att, attribute.value || true);
		}
	});
};

Handling styles

The setAttribute() method also won’t work for styles. If the att value is style, the value will be an a string of styles separated by a semicolon, like this.

var attribute = {
	att: 'style',
	value: 'background-color: rebeccapurple; color: white;'
};

To add those to the element, we need to convert each item into an array, loop through each one, and add it with the style property.

First, let’s create a getStyleMap() function to convert our style string into an array. We’ll accept the styles string as an argument.

/**
 * Create an array map of style names and values
 * @param  {String} styles The styles
 * @return {Array}         The styles
 */
var getStyleMap = function (styles) {
	// Code goes here...
};

First, we’ll split our string into an array with the String.split() method, passing in a semicolon (;) as the delimiter.

var getStyleMap = function (styles) {
	return styles.split(';');
};

With our previous example, this would return an array like this:

var styles = [
	'background-color: rebeccapurple',
	'color: white'
];

Now let’s split each style into it’s own set of key/value pairs. We’ll use the Array.reduce() method to create a new array.

If there’s no property name—if the first value in the style item is the color (:), we’ll ignore it. Otherwise, we’ll split() it again, this time using a colon as the delimiter.

var getStyleMap = function (styles) {
	return styles.split(';').reduce(function (arr, style) {
		if (style.trim().indexOf(':') > 0) {
			var styleArr = style.split(':');
		}
	}, []);
};

Then, we’ll push an object with our style properties into our new array (arr).

var getStyleMap = function (styles) {
	return styles.split(';').reduce(function (arr, style) {
		if (style.trim().indexOf(':') > 0) {
			var styleArr = style.split(':');
			arr.push({
				name: styleArr[0] ? styleArr[0].trim() : '',
				value: styleArr[1] ? styleArr[1].trim() : ''
			});
		}
		return arr;
	}, []);
};

Back in our addAttributes() method, we can now get an array of style properties.

Then, we’ll loop through each one with the forEach() method and add it to the element with the style property.

var addAttributes = function (elem, atts) {
	atts.forEach(function (attribute) {
		// If the attribute is a class, use className
		// Else if it's style, diff and update styles
		// Otherwise, set the attribute
		if (attribute.att === 'class') {
			elem.className = attribute.value;
		} else if (attribute.att === 'style') {
			var styles = getStyleMap(attribute.value);
			styles.forEach(function (style) {
				elem.style[style.name] = style.value;
			});
		} else {
			elem.setAttribute(attribute.att, attribute.value || true);
		}
	});
};

Injecting the new element into the DOM

Now that we’ve got our element, we can inject it into the DOM, again using the appendChild() method.

// Diff each item in the templateMap
templateMap.forEach(function (node, index) {

	// If element doesn't exist, create it
	if (!domMap[index]) {
		elem.appendChild(makeElem(templateMap[index]));
		return;
	}

});

To be continued…

This was a lot for one article, and this is a good place to start.

Tomorrow, we’ll pick this back up. We’ll look at how to update element types, add and remove classes, styles, and other attributes, and update content within an element.