Rethinking DOM Traversal

by Brian Rinaldi on May 12, 2014

The modern web is always changing, and this article is more than two years old.

By Brian Rinaldi

In web development, as in life, sometimes we develop patterns in how we think about a topic or achieve a common task. This is necessary, as to do otherwise would waste a lot of mental cycles on trivial problems we’ve already solved. However, these patterns can be hard to break, even when perhaps the pattern is no longer the optimal solution.

One of the most common tasks a front-end developer may handle is traversing the DOM (Document Object Model). Usually this involves locating a DOM element and then manipulating it and/or its contents. Most of us have been using jQuery for this task for the past seven or eight years, as it is one of the core things jQuery has handled well since day one.

In “Internet time” though, seven years is forever. Back in 2006, when jQuery was initially released, Flash was hot and and Rich Internet Applications built with Flex or Silverlight or even Laszlo seemed like they might be the future of the web. Who needs the DOM when plugins were the future of web development.

The point is, a lot has changed. The browser can now handle DOM traversal in ways that weren’t possible when jQuery was invented. Plus, new libraries have come along that offer a totally different approach to DOM traversal than the one we’ve grown so accustomed to. So, it seems like a fair time to ask, do we even need jQuery for DOM traversal anymore?

How We Usually Do DOM Traversal

Before we explore the native methods available or alternative libraries. It’s useful for us to first remind ourselves of what the common DOM traversal methods that come with jQuery are, so that we know what it is we’ll need to replace.

Selectors

Of course, the first thing we need are some selectors. jQuery offered a CSS-like syntax for selecting DOM elements, which helped make it feel comfortable. Here are some simple selectors:

$("#item") // select an element with the id of "item"

$(".fancybutton") // select an element with a class of "fancybutton"

$("li:even") // select the even numbered items in a list

$("ul li:nth-child(3)") // select the 3rd list item in an unordered list

Most readers are probably already comfortable with the basics of selectors in jQuery. Once you’ve selected a DOM element, you can begin travering from that point – so let’s look at the typical DOM traversal methods.

DOM Traversal Methods

Before we begin exploring the various DOM traversal methods, it may be useful to have a simple DOM to work with, that way we can clearly illustrate what each method would do.

<html>
<head>
    <title>DOM Traversal Sample</title>
    <style>
        .cell {
            height: 50px;
            width: 50px;
            border: 1px solid #000000;
        }
   </style>
</head>
<body>
    <table class="board">
        <tr id="row1" class="row">
            <td id="cell11" class="cell"></td>
            <td id="cell12" class="cell"></td>
            <td id="cell13" class="cell"></td>
        </tr>
        <tr id="row2" class="row">
            <td id="cell21" class="cell"></td>
            <td id="cell22" class="cell"></td>
            <td id="cell23" class="cell"></td>
        </tr>
        <tr id="row3" class="row">
            <td id="cell31" class="cell"></td>
            <td id="cell32" class="cell"></td>
            <td id="cell33" class="cell"></td>
        </tr>
    </table>
</body>
</html>

Our example is trivial, but this simple table of three columns and three rows makes it easy to visualize what we are selecting. Below are the most commonly used DOM traversal methods along with an illustration showing what they would select.

Note: In the illustrations, the highlighted element is the DOM node that is selected via the selector and the X’s indicate the nodes selected by the traversal method.

siblings()

The siblings() method gets the sibling elements (i.e. DOM nodes with the same immediate parent element) of the selected node. In the case of a table cell, that would return the other adjoining cells.

$("#cell11").siblings()

siblings cell

In the case of a row, this would return the other rows.

$("#row1").siblings()

siblings row

parent() and parents()

The parent() method returns the immediate parent node of the selected DOM node. Thus, for a cell it would return the row parent.

$("#cell23").parent()

parent

For a row, it would return the table parent.

$("#row3").parent()

parent row

The parents() method returns all parents for the selected node all the way up the dom tree. Thus, for a cell it would return the row and the table (technically speaking, it will also return the body and the html nodes but for the purposes of the illustration, I’ve left those out).

$("#cell33").parents()

parents

next() and nextAll()

The next() method returns the sibling that immediately follows the selected node. For instance, if we select a cell, it would return only the next cell in the same row.

$("#cell21").next()

next

However, nextAll() would return all of the subsequent cells that follow. This differs from siblings() in that it does not return any siblings that precede the selected DOM node.

$("#cell21").nextAll()

nextAll

prev() and prevAll()

The ‘prev()’ and ‘prevAll’ methods work identically to ‘next()’ and ‘nextAll()’ except they select the preceding siblings.

$("#cell23").prev()

prev

$("#cell23"). prevAll()

prevAll

closest() and find()

Some DOM traversal methods require a selector of their own. For example, the ‘closest()’ method will get the DOM element that matches the passed selector and is closest to the selected node within the DOM tree. It’s important to note that it does this by traversing up the DOM tree, meaning it won’t return any elements that are children of the currently selected node.

$("#cell32").closest(".row")

closest

Unlike closest(), which traverses up the DOM tree, find() traverses down. It will return any elements that match the passed selector (unlike closest() which only returns one).

$("#row2").find(".cell")

find

Do We Even Need jQuery?

It turns out that in the years since jQuery first came about, the browser has greatly improved in its ability to handle basic DOM traversal. In fact, under the covers, wherever possible, jQuery is just using many of these methods, meaning that, in many cases, we are replacing one line of JavaScript with one line of jQuery in our code – but with the added heft of the entire jQuery library.

See Also:  Getting started with Redux using the Mullet Stack

Given these improvements, its fair to ask, do we even need jQuery at all for DOM traversal? Let’s examine how we might replace all of the code listed above using plain JavaScript.

Selectors

Using document.querySelector() and document.querySelectorAll(), we can easily replace the selectors I listed above. If you are so inclined, you could even map these functions to $ and you’d hardly notice jQuery was missing. Let’s see how.

Here are the replacements for the four selectors listed previously.

document.querySelector("#item")

document.querySelectorAll(".fancybutton")

//…pass!

document.querySelectorAll("ul")[0].children[2]

As you can see, it’s pretty much a one line of plain JavaScript for one line of jQuery replacement.

OK – So I Cheated!

…but only just a little.

Most selectors in jQuery are straight CSS selectors. Since document.querySelector() also uses CSS selectors, the replacement is simple. However, some (like :even) are jQuery specific. The following function is based upon the implementation of :even within jQuery and will accomplish the same thing.

function selectEven(elems) {
    var i = 0, length = elems.length, matches = [];

    for ( ; i < length; i += 2 ) {
        matches.push(elems[i]);
    }
    return matches;
}

selectEven(document.querySelectorAll("li"));

The method isn’t complicated, but it does mean you have to call selectEven() as opposed to filtering with the :even selector.

The above method will work for any element, but you could also, in cases where all the items are children of a particular element (which is commonly the case), replace with this one-liner:

document.querySelectorAll("ul li:nth-child(even)");

siblings()

As with a number of the DOM traversal methods, there is no native replacement for siblings(), so we’ll need to replace it with a method of our own.

function getSiblings(elem) {
    var i = 0, n = elem.parentNode.firstChild, matches = [];

    for ( ; n; n = n.nextSibling ) 
        if ( n.nodeType == 1 && n != elem)
            matches.push( n );
                return matches;
}

Once this method is written, we’d could relatively easily replace the jQuery examples from above.

getSiblings(document.querySelector("#cell11"))

getSiblings(document.querySelector("#row1"))

The returned elements would be the same.

parent() and parents()

There is a straightforward one line replacement for the jQuery parent() method. The prior examples could be replaced with:

document.querySelector("#cell21").parentNode

document.querySelector("#row3").parentNode

Once again, these will return the same DOM node. However, there is no simple replacement for parents(), so we’ll have to write one.

function getParents(elem) {
    var n = elem.parentNode; matches = [];

    for ( ; n; n = n.parentNode ) {
        if ( n.nodeType == 9) {
            return matches;
        }
        else if (n.nodeType == 1)
            matches.push( n );
    }
}

Using this method, we could replace jQuery’s parents() with our getParents().

getParents(document.querySelector("#cell33"))

next() and nextAll()

There are actually numerous potential replacements for these methods (note: Thanks to Keith Clark for the tip). There is a nextSibling property on Node, which you might think would work. This is the property used internally by jQuery to handle this method. However, in some browsers, this returns a blank space, which is often just there for code formatting, so a replacement needs to accommodate this.

For simplicities sake, I decided to replace both methods with a single method but it is based upon the way it is implemented inside jQuery.

function getNext(elem,all) {
    var n = elem.nextSibling, matches = [];

    for ( ; n; n = n.nextSibling ) {
        if ( n.nodeType == 1) {
            matches.push( n );
            if (!all) return matches;
        }
    }
    return matches;
}

To replace jQuery’s next() we simple call getNext() and supply the DOM node:

getNext(document.querySelector("#cell21"))

To replace nextAll(), we simply supply the second parameter as true:

getNext(document.querySelector("#cell21"),true)

However, it turns out that you can also get the next element using just specialized selectors. You can select the next element using the adjacent selector:

document.querySelector("#cell21+*")

Or recreate nextAll() using the less strict sibling combinator.

document.querySelectorAll("#cell21~*")

In addition, the childNode API has a property called nextElementSibling that will eliminate the behavior of returning whitespace elements. So next() could be recreated as:

document.querySelector("#cell21").nextElementSibling

I have not done cross-browser testing on these methods but, according to MDN, the support is excellent (though I am not sure why jQuery doesn’t use it).

prev() and prevAll()

As you would expect, this is similar to the next() replacement. We utilize previousSibling (as jQuery does) but handle situations where it returns spaces.

function getPrev(elem,all) {
    var n = elem.previousSibling, matches = [];

    for ( ; n; n = n.previousSibling ) {
        if ( n.nodeType == 1) {
            matches.push( n );
            if (!all) return matches;
        }
    }
    return matches;
}

Once again, our replacement for the prior examples is simple. For prev(), we pass just the DOM node:

getPrev(document.querySelector("#cell23"))

And for prevAll() we pass the secondary argument as true:

getPrev(document.querySelector("#cell23"),true)

As with next(), the childNode API has a property called previousElementSibling that will eliminate the behavior of returning whitespace elements. So prev() could be recreated as:

document.querySelector("#cell23").previousElementSibling

Again, I have not done cross-browser testing on this method, but according to MDN, the support is excellent (though, again I am not sure why jQuery doesn’t use it).

closest() and find()

As you hopefully recall from earlier in the article, closest() and find() took additional selectors as arguments. There is also no direct replacement for closest() but we can easily achieve one using the matchesSelector() method.

function getClosest(elem, selector) {
    var n = elem, el;

    for ( ; n; n = n.parentNode ) {
        el = n;
        if ( n.matchesSelector(selector))
            return el;
    }
}

This method is pretty simple and works just fine. Simply pass the selected node as the first argument and the selector as the second and we have a replacement for the jQuery example:

getClosest(document.querySelector("#cell32"),".row");

The problem is that matchesSelector support is not yet universal and is often prefixed (in fact, the actual spec appears to be matches and not matchesSelector but it hasn’t been implemented that way in most browsers). So, if you need to support legacy browsers and need to use closest(), things get complicated.

On a positive note, however, replacing find() is very simple.

document.querySelector("#row2").querySelectorAll(".cell")

You can chain selectors using querySelector() and querySelectorAll(), which is how we can easily replace the find() jQuery example from earlier.

Maybe You Do Need jQuery!

While replacing any individual method wasn’t overly complex, we’ve now written six new methods just to replicate the basic jQuery examples from earlier in the article. Our methods may work, but overall its nowhere near as robust or easy to use as jQuery. So maybe it turns out that we do need jQuery after all!

See Also:  Why you should limit JavaScript — and how to do it

…or do we? Someone must have thought of new approaches to DOM traversal in the eight years since jQuery arrived. Perhaps there a better, more modern way to do this that leverages of some of the browser API improvements.

DOM Traversal with HTML.js

Nearly a year ago, I wrote about a library called Voyeur. It dared to take a completely different approach to DOM traversal by allowing you to chain together DOM nodes in a very intuitive and comfortable fashion. For example, rather than selecting a node via $("ul li"), you would simple use something more like ul.li. This seemed to make complete sense in many ways.

And then it didn’t. The author of Voyeur, Adrian Cooney, abandoned the project because, as he explained it, it could never achieve the performance needed to actually use it in a production application (he specifically cited the performance around Object.defineProperty as the primary issue).

But the story didn’t end there. Nathan Bubna picked up where Voyeur left off and created a library called HTML.js that worked to improve some of the issues with Voyeur.

Let’s look at how this approach is different and whether it might offer a better solution.

Selectors

The replacement for the selectors from my prior jQuery example are pretty straightforward since the query() method of HTML.js works similarly to jQuery. Once again, the replacement for :even is slightly convoluted, but at least we were able to get it to a single line (well, sort of).

HTML.query("#item")

HTML.query(".fancybutton")

HTML.query("li").only(function(o,i){ return !(i%2); })

HTML.query("ul").only(3)

Arguably, however, this is not an improvement nor is it demonstrably different than jQuery.

siblings()

We can also replace jQuery’s siblings() with what can be called one line of JavaScript using HTML.js. The replacement for the jQuery examples from earlier would be:

HTML.query("#row1").div.only(function(o,i){ if (!o.matchesSelector("#cell11")) return o; })

HTML.query(".board").div.only(function(o,i){ if (!o.matchesSelector("#row1")) return o; })

Once again, the fact that this is a single line is more of a technicality than anything else and certainly can’t be called an improvement over jQuery.

parent() and parents()

Since HTML.js works with standard DOM nodes, we can rely on the standard methods provided by the browser for many functions. This means that, rather than have a method for parent(), we simple use parentNode.

HTML.query("#cell23").parentNode

HTML.query("#row3").parentNode

This could be considered a small improvement over the jQuery method but really isn’t an improvement over the straight JavaScript examples shown previously.

However, since there is no browser replacement for parents(), we need to implement a method for it very similar to the straight JavaScript example from before.

function getParents(elem) {
    var n = elem; matches = [];

    for ( ; n; n = n.parentNode ) {
        if ( n.nodeType == 9) {
           return matches;
        }
        else if (n.nodeType == 1)
            matches.push( n );
    }
}

The only real difference here is that since we are using HTML.js, we pass a DOM node using that.

getParents(HTML.query("#cell33"))

Definitely not an improvement.

WTF! Now You’re Just Messing with Us!

Ok. I admit that so far, using HTML.js seems like a mix of the worst of both worlds. However, that’s really because we aren’t relying on its strengths and instead we’re trying to fit it into a jQuery box.

Where HTML.js excels is in traversing down the DOM tree in an intuitive way using chaining of DOM elements.

HTML.body.table.tbody.tr.td

The result of this would be all the table cells in the first row.

Chaining in HTML.js

Our simple example HTML did not actually have the tbody tags in it, but in my testing these were automatically added to the DOM (at least in Chrome) and thus were necessary.

HTML.js offers some methods to easily filter the results.

HTML.body.table.tbody.tr.only(1)

This would select the second row in the table (unlike the jQuery or straight JavaScript selectors, this is using a zero-based array).

Chaining in HTML.js

You can continue chaining as necessary to get the results you want.

HTML.body.table.tbody.tr.only(1).query(".cell")

Chaining in HTML.js

HTML.js also provides useful methods like each() for traversing through and acting upon the results.

HTML.body.table.tbody.tr.query(".cell").each(function(el, i, all) {
    // do something with each table cell node
});

For example,the following code:

HTML.query("body > table tr td.cell").each('textContent','X')

…would place an “X” inside every cell in the table.

each in HTML.js

One thing I did notice was that, while it was very easy to move down the DOM, it was much less easy to move back up. In theory, you could rely upon built-in methods like previousSibling or parent, but as we’ve already covered, these offer some complications which you would still need to resolve.

Another complication is that it doesn’t offer a complete set of alternatives for the jQuery DOM traversal methods we covered previously.

Still, I feel it’s worth experimenting with HTML.js as at least offers a fresh perspective on the task of DOM traversal.

Wrapping Up

Based upon my experiments using jQuery, plain JavaScript and HTML.js, here are my conclusions.

  • jQuery still offers the easiest and cleanest API for DOM traversal…however, jQuery might be more than you need.
  • In many cases, you can replace 1 line of jQuery with 1 line of plain JavaScript…however, it’s not always so easy and you may end up rewriting (and maintaining) a lot of methods.
  • HTML.js offers a unique way to traverse the DOM that can feel intuitive in many cases…however, it’s missing a lot of methods that you might need.

In my opinion, the net result is that most people should probably just stick with jQuery. It’s API is still the most complete and the so-called “bloat” is often overstated.