Skip to main content
0:01est. 30 min

Tree Build Diff

// https://codepen.io/cferdinandi/pen/GRKjeLv?editors=1010
var support = (function () {
if (!window.DOMParser) return false;
var parser = new DOMParser();
try {
parser.parseFromString('x', 'text/html');
} catch(err) {
return false;
}
return true;
})();

/**
* Convert a template string into HTML DOM nodes
* @param {String} str The template string
* @return {Node} The template HTML
*/
var stringToHTML = function (str) {

// If DOMParser is supported, use it
if (support) {
var parser = new DOMParser();
var doc = parser.parseFromString(str, 'text/html');
return doc.body;
}

// Otherwise, fallback to old-school method
var dom = document.createElement('div');
dom.innerHTML = str;
return dom;

};

/**
* Create an array of the attributes on an element
* @param {NamedNodeMap} attributes The attributes on an element
* @return {Array} The attributes on an element as an array of key/value pairs
*/
var getAttributes = function (attributes) {
return Array.prototype.map.call(attributes, function (attribute) {
return {
att: attribute.name,
value: attribute.value
};
});
};

/**
* Create a DOM Tree Map for an element
* @param {Node} element The element to map
* @param {Boolean} isSVG If true, node is within an SVG
* @return {Array} A DOM tree map
*/
var createDOMMap = function (element, isSVG) {
return Array.prototype.map.call(element.childNodes, (function (node) {
var details = {
content: node.childNodes && node.childNodes.length > 0 ? null : node.textContent,
atts: node.nodeType !== 1 ? [] : getAttributes(node.attributes),
type: node.nodeType === 3 ? 'text' : (node.nodeType === 8 ? 'comment' : node.tagName.toLowerCase()),
node: node
};
details.isSVG = isSVG || details.type === 'svg';
details.children = createDOMMap(node, details.isSVG);
return details;
}));
};

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;
}, []);
};

var removeStyles = function (elem, styles) {
styles.forEach(function (style) {
elem.style[style] = '';
});
};

var changeStyles = function (elem, styles) {
styles.forEach(function (style) {
elem.style[style.name] = style.value;
});
};

var diffStyles = function (elem, styles) {

// Get style map
var styleMap = getStyleMap(styles);

// Get styles to remove
var remove = Array.prototype.filter.call(elem.style, function (style) {
var findStyle = styleMap.find(function (newStyle) {
return newStyle.name === style && newStyle.value === elem.style[style];
});
return findStyle === undefined;
});

// Add and remove styles
removeStyles(elem, remove);
changeStyles(elem, styleMap);

};

var removeAttributes = function (elem, atts) {
atts.forEach(function (attribute) {
// If the attribute is a class, use className
// Else if it's style, remove all styles
// Otherwise, use removeAttribute()
if (attribute.att === 'class') {
elem.className = '';
} else if (attribute.att === 'style') {
removeStyles(elem, Array.prototype.slice.call(elem.style));
} else {
elem.removeAttribute(attribute.att);
}
});
};

/**
* Add attributes to an element
* @param {Node} elem The element
* @param {Array} atts The attributes to add
*/
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') {
diffStyles(elem, attribute.value);
} else {
elem.setAttribute(attribute.att, attribute.value || true);
}
});
};

/**
* Diff the attributes on an existing element versus the template
* @param {Object} template The new template
* @param {Object} existing The existing DOM node
*/
var diffAtts = function (template, existing) {

// Get attributes to remove
var remove = existing.atts.filter(function (att) {
var getAtt = template.atts.find(function (newAtt) {
return att.att === newAtt.att;
});
return getAtt === undefined;
});

// Get attributes to change
var change = template.atts.filter(function (att) {
var getAtt = find(existing.atts, function (existingAtt) {
return att.att === existingAtt.att;
});
return getAtt === undefined || getAtt.value !== att.value;
});

// Add/remove any required attributes
addAttributes(existing.node, change);
removeAttributes(existing.node, remove);

};

/**
* Make an HTML element
* @param {Object} elem The element details
* @return {Node} The HTML 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);
}

// 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;

};

/**
* 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) {

// 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) {

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

// If element is not the same type, replace it with new element
if (templateMap[index].type !== domMap[index].type) {
domMap[index].node.parentNode.replaceChild(makeElem(templateMap[index]), domMap[index].node);
return;
}

// If attributes are different, update them
diffAtts(templateMap[index], domMap[index]);

// If content is different, update it
if (templateMap[index].content !== domMap[index].content) {
domMap[index].node.textContent = templateMap[index].content;
}

// If target element should be empty, wipe it
if (domMap[index].children.length > 0 && node.children.length < 1) {
domMap[index].node.innerHTML = '';
return;
}

// If element is empty and shouldn't be, build it up
// This uses a document fragment to minimize reflows
if (domMap[index].children.length < 1 && node.children.length > 0) {
var fragment = document.createDocumentFragment();
diff(node.children, domMap[index].children, fragment);
elem.appendChild(fragment);
return;
}

// If there are existing child elements that need to be modified, diff them
if (node.children.length > 0) {
diff(node.children, domMap[index].children, domMap[index].node);
}

});

};

// The new UI
var template = `
<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>`;

// 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);


/// HTML
/*
<div id="app">

<h1>Starting at Hogwarts</h1>

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

</div>
*/


// other references
// https://gitlab.com/sebdeckers/canva-interview/-/tree/master/round3/src

// https://gomakethings.com/dom-diffing-with-vanilla-js-part-1
// https://gomakethings.com/dom-diffing-with-vanilla-js-part-2

// https://programming.vip/docs/realization-and-analysis-of-virtual-dom-diff-algorithm.html

// https://github.com/Lin-Kyle/virtualdom-diff/tree/master/src

// https://github.com/yining1023/algorithm-questions


// EXPLAINATION

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 0if 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;
}

});

DOM diffing with vanilla JS: part 2
Yesterday, we started exploring DOM diffing with vanilla JS.

We got as far creating new elements. Today, we’re going to pick things back and look at how to update element types, add and remove classes, styles, and other attributes, and update content within an element.

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 first part in the series came out yesterday.

Where we left off #
Here’s where we left off yesterday. This Array.forEach() loop runs inside our diff() function.

// 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;
}

});
Replacing elements #
If the element in the current UI (the domMap) is a different type than the one in the desired UI (the templateMap), we need to change it.

To do that, we can use the replaceChild() method, which replaces one element with another. You call it on the parentNode of the element you want to replace, and pass in the new element and existing one as arguments.

In our case, we’ll use our makeElem() method to create the new element. Then, we’ll return so the rest of the tasks in the loop don’t run.

// 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;
}

// If element is not the same type, replace it with new element
if (templateMap[index].type !== domMap[index].type) {
domMap[index].node.parentNode.replaceChild(makeElem(templateMap[index]), domMap[index].node);
return;
}

});
Updating attributes #
If the element isn’t new and doesn’t need to be replaced, we need to check if any attributes have changed and need to be updated.

We’ll create a helper function—diffAtts()for that. We’ll pass in the current item in the template and the actual DOM as arguments.

/**
* Diff the attributes on an existing element versus the template
* @param {Object} template The new template
* @param {Object} existing The existing DOM node
*/
var diffAtts = function (template, existing) {
// ...
};
Let’s first get an array of attributes that need to be removed. We can use the Array.filter() method and the Array.find() method for this.

We’ll call Array.filter() on the existing UI’s atts. For each attribute on the existing element in the DOM, we’ll use Array.find() to see if that element also exists for the template element. If not (if it’s undefined), we’ll add it to the array of items to be removed.

var diffAtts = function (template, existing) {

// Get attributes to remove
var remove = existing.atts.filter(function (att) {
var getAtt = template.atts.find(function (newAtt) {
return att.att === newAtt.att;
});
return getAtt === undefined;
});
};
Then we’ll repeat the process for items that need to be added or updated.

We’ll use Array.filter() to create a new array for the template element’s attributes. In the callback function, we’ll use Array.find() to look for that attribute on the existing element.

If the attribute doesn’t exist, or if it does but has a different value, we’ll add it to the list of attributes to update.

var diffAtts = function (template, existing) {

// Get attributes to remove
var remove = existing.atts.filter(function (att) {
var getAtt = template.atts.find(function (newAtt) {
return att.att === newAtt.att;
});
return getAtt === undefined;
});

// Get attributes to change
var change = template.atts.filter(function (att) {
var getAtt = find(existing.atts, function (existingAtt) {
return att.att === existingAtt.att;
});
return getAtt === undefined || getAtt.value !== att.value;
});

};
Next, let’s create two helper functions to handle adding and removing attributes. We’ll pass the current element, and the array of items to change or remove in as arguments.

var diffAtts = function (template, existing) {

// Get attributes to remove
var remove = existing.atts.filter(function (att) {
var getAtt = template.atts.find(function (newAtt) {
return att.att === newAtt.att;
});
return getAtt === undefined;
});

// Get attributes to change
var change = template.atts.filter(function (att) {
var getAtt = find(existing.atts, function (existingAtt) {
return att.att === existingAtt.att;
});
return getAtt === undefined || getAtt.value !== att.value;
});

// Add/remove any required attributes
addAttributes(existing.node, change);
removeAttributes(existing.node, remove);

};
Updating attributes #
Yesterday, we looked at building out an addAttributes() method. We need to make a change to how it handles styles.

In the original version, it just added styles. Now, we need to diff the existing styles and add/remove them as needed. Let’s add a diffStyles() method, passing in the element and desired styles.

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') {
diffStyles(elem, attribute.value);
} else {
elem.setAttribute(attribute.att, attribute.value || true);
}
});
};
The diffStyles() method will work a bit like the diffAtts() method.

First, we’ll use the getStyleMap() method we created yesterday to get an array of styles. Then, we’ll use the Array.prototype/call() trick to use the Array.filter() method on the current element’s styles.

We’ll again use the Array.find() method to see if that style also exists on the new element, and if not, we’ll add it to an array of styles to remove.

We’ll pass the element and that array into a removeStyles() function. We’ll also pass the element and the styleMap array into a changeStyles() function.

var diffStyles = function (elem, styles) {

// Get style map
var styleMap = getStyleMap(styles);

// Get styles to remove
var remove = Array.prototype.filter.call(elem.style, function (style) {
var findStyle = styleMap.find(function (newStyle) {
return newStyle.name === style && newStyle.value === elem.style[style];
});
return findStyle === undefined;
});

// Add and remove styles
removeStyles(elem, remove);
changeStyles(elem, styleMap);

};
In our removeStyles() function, we’ll loop through each style in the array and set its value to an empty string on the element.

var removeStyles = function (elem, styles) {
styles.forEach(function (style) {
elem.style[style] = '';
});
};
In the changeStyles() function, we’ll loop through each style in the array and set its value.

var changeStyles = function (elem, styles) {
styles.forEach(function (style) {
elem.style[style.name] = style.value;
});
};
Removing attributes #
Removing attributes is a lot more straightforward.

If the attribute is class, we’ll set className to an empty string. If it’s style, we’ll pass an array of styles on the element into the removeStyles() method we just created. Otherwise, we’ll use the removeAttribute() method to remove it.

var removeAttributes = function (elem, atts) {
atts.forEach(function (attribute) {
// If the attribute is a class, use className
// Else if it's style, remove all styles
// Otherwise, use removeAttribute()
if (attribute.att === 'class') {
elem.className = '';
} else if (attribute.att === 'style') {
removeStyles(elem, Array.prototype.slice.call(elem.style));
} else {
elem.removeAttribute(attribute.att);
}
});
};
Running the diffAtts() method #
Now, after all that, we can finally run our diffAtts() method.

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

// If element doesn't exist, create it
if (!domMap[index]) {
//...
}

// If element is not the same type, replace it with new element
if (templateMap[index].type !== domMap[index].type) {
// ...
}

// If attributes are different, update them
diffAtts(templateMap[index], domMap[index]);

});
We don’t need to stop the loop here, as other things might also be different.

Updating content #
Next, let’s check if the content in the template element is the same as in the existing element in the UI. If they’re not, we’ll use the textContent method to update the DOM.

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

// ...

// If attributes are different, update them
diffAtts(templateMap[index], domMap[index]);

// If content is different, update it
if (templateMap[index].content !== domMap[index].content) {
domMap[index].node.textContent = templateMap[index].content;
return;
}

});
Adding child elements #
Now let’s look at how to handle child elements.

If the existing UI has child elements (if the length of the children array is greater than 0), and the template does not (if it’s length is less than 1), we’ll use innerHTML to wipe out the content.

We could loop through each child element and remove it, but that would trigger a lot of reflows. This approach should be better for performance.

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

// ...

// If content is different, update it
if (templateMap[index].content !== domMap[index].content) {
domMap[index].node.textContent = templateMap[index].content;
return;
}

// If target element should be empty, wipe it
if (domMap[index].children.length > 0 && node.children.length < 1) {
domMap[index].node.innerHTML = '';
return;
}

});
If the template element has child elements, we need to add them.

The simplest way to do that is to recursively pass template element’s children, the current element’s children, and current element back into the diff() function.

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

// ...

// If target element should be empty, wipe it
if (domMap[index].children.length > 0 && node.children.length < 1) {
domMap[index].node.innerHTML = '';
return;
}

// If there are existing child elements that need to be modified, diff them
if (node.children.length > 0) {
diff(node.children, domMap[index].children, domMap[index].node);
}

});
This works great.

But… if the current DOM element is empty and the template has a lot of child elements, it will trigger a bunch of a reflows. This is bad for performance and can introduce some jank into the front end.

If that’s the caseif the current DOM element has no children and the template does—we’ll instead create a document fragment and diff that.

A document fragment is a new document element that you can add child elements to, but that isn’t attached to the current document. As a result, modifying it will not trigger reflows.

We’ll pass that into diff() as the element to append to. Then, we’ll append it into the element so that only one reflow happens with all of our new elements in it.

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

// ...

// If target element should be empty, wipe it
if (domMap[index].children.length > 0 && node.children.length < 1) {
domMap[index].node.innerHTML = '';
return;
}

// If element is empty and shouldn't be, build it up
// This uses a document fragment to minimize reflows
if (domMap[index].children.length < 1 && node.children.length > 0) {
var fragment = document.createDocumentFragment();
diff(node.children, domMap[index].children, fragment);
elem.appendChild(fragment);
return;
}

// If there are existing child elements that need to be modified, diff them
if (node.children.length > 0) {
diff(node.children, domMap[index].children, domMap[index].node);
}

});
Putting it all together #
There’s obviously a lot going on with this code. Here’s a working demo for you to play with.

I learned two big things from this project:

You can do a lot of amazing stuff with a relatively small amount of code.
The engineering behind bigger frameworks like React and Vue is absolutely amazing, and I’m impressed with what those people have built.