jQuery in Vanilla JavaScript

I love to eat, which means I love watching competitive cooking shows. One staple of these shows is the "deconstructed" meal: take the ingredients for something commonplace, like a hamburger, and then prepare a new version where those same ingredients are used to remix and restructure the old dish. It's a little silly and pretentious, but it's also a cool way to think about how flavor and presentation come together to create a taste.

On the web, a classic flavor is jQuery: the ur-library that tamed a wildly inconsistent range of browsers, and made it possible to build interactive pages without wanting to stick your head in the microwave. But browsers kept improving, and some of jQuery's features became antipatterns. It's a perfect example of this book's thesis: these days, you probably don't need everything that the library does:

That doesn't mean that jQuery is worthless—far from it! The fundamental pattern of selection and iteration is a good one, and we still need to be able to perform DOM manipulation to add and remove classes, or to listen for events. But let's see if we can make a "deconstructed jQuery" with a similar API flavor, but broken into micro-modules that we can mix and match as demanded. That way, we don't have to load a big library full of stuff we don't need just for basic tasks.

To replace jQuery's core functionality, we need to handle a few basic tasks. We need to be able to select multiple elements and perform tasks on all of them. We need to be able to walk back up the DOM tree, given a child element and a selector. And we need to be able to add delegated event listeners, which are crucial for interactive apps that may replace big chunks of the page with new content. Luckily, each of these tasks combines with the next in a neat, composable way.

Making selections

Let's start by making some selections. After jQuery's popularity skyrocketed, browsers added a similar querySelectorAll function for searching the document. Unfortunately, this function wasn't as useful as a regular jQuery search was, because the object wasn't an array that you could loop through. Instead, it was a NodeList, an array-like object that had numbered items and a length, but lacked important methods like forEach or map.

Luckily, it's pretty easy to convert a NodeList into a JavaScript array. The "spread" syntax, which looks like [...value], lets us take any list-ish object and smear it into array form. Here's the code for a jQuery-like $() function that can search within either the document or a chosen child node:

var $ = (selector, d = document) => [...d.querySelectorAll(selector)];

This function returns an array containing all the selected nodes. If you call it with a different node as the second argument, it'll use that as the root of the search, instead of looking through the entire document. Pretty handy!

In jQuery, once you made a selection, any methods you called on the object would be applied to everything in it. Our function isn't quite as powerful, but since it returns an array, we can use the forEach() method to iterate through it with an arrow function. For example, to change the style of every link on the page, we might do this:

$("a").forEach(a => a.style.color = "red");

Let's try a more realistic example. jQuery make it easy to add or remove classes from a group of elements. Browser developers knew that manually manipulating the className property of an element was too cumbersome, so they added the classList property, which supports operations for add, remove, contains, and toggle operations. So, for example, to update a few classes on the page:

//jQuery version
$("tab.selected").removeClass("hidden");
$("tab.disabled").addClass("hidden");

//Our module
$("tab.selected").forEach(el => el.classList.remove("hidden"));
$("tab.disabled").forEach(el => el.classList.add("hidden"));

It's a little more verbose, sure. But I would argue that the meaning is more clear, and that it's easier to think about what you can "do" to our version of the object, since the answer is "anything I can do to an array."

Of course, sometimes, you just want one element, and then iterating over an array with just one item doesn't make a lot of sense. So let's add a method to our $() function that grabs just one item from the page:

$.one = (s, d = document) => d.querySelector(s);

//changing just one item is easy
$.one("tab.active").classList.toggle("active");

Listening for events

Another traditional use for jQuery is to register for events. Event handling used to be very inconsistent between browsers, with some passing an event into the listener function, and others (well, just IE) setting a window.event property instead (this worked, because JS is single-threaded, but it's an ugly way to write code).

These days, however, pretty much everything supports the standard addEventListener function, and jQuery's habit of "normalizing" properties on the event object can actually backfire when we want to pay attention to non-input events, like media playback or file drag/drop. Binding an event listener to a selection of elements using our new $ function turns out to be pretty easy:

//Arrow functions inherit this, which we don't want
var listener = function(e) {
  console.log(this, e);
};

$(".event-handlers").forEach(el => el.addEventListener("click", listener);

When you click on the elements that we selected, the browser should log out the specific element clicked (that's the this value) and an event with additional details (mouse position, event type, and so on). I like to define the listener as a separate variable outside of the loop: if you define it inline, you're technically creating a new function for each individual element, which wastes memory. Defined separately, each element shares the same listener, but we can still react to the specific item by using this.

Delegating events

Let's put all of this code together to duplicate one of jQuery's most useful features, event delegation. Of course, to understand delegation, we have to understand how events work.

Imagine that you have the following HTML code:

<main class="story">
  <section class="primary">
    <div class="padding-box">
      <button class="event-target">
        Click me!
      </button>
    </div>
  </section>
</main>

Now, imagine that a user clicks the button. We often think about events taking place "on" an element, as if they start there directly. But that's not actually what happens, as the event flow section of the W3C spec shows. Instead, the event starts at the top of the document and travels down into the tree. This is the "capture" phase, and although you can technically "trap" an event during this phase, it's pretty rare that you'd want to.

Once it reaches the actual target element where the event "occurred," the real action starts: it triggers any listeners there, and then (for most events) it enters the "bubble" phase: the event walks back up the tree to the document root, visiting each parent node along the way. Each ancestor element gets a copy of the event and an opportunity to respond to it in turn. If you think about it, this makes sense: clicking an image inside of a link should still trigger the link, after all. Containing elements need to be able to respond when you interact with their children in order to be consistent.

Most events do not trigger listeners in the capture phase, so we tend to ignore it. And not all events bubble — media events, for example, fire only on their target element and then vanish. But event bubbling can be a powerful tool, because we can attach a single listener to a common ancestor element (say, the "primary" section in the code above) and be notified of any clicks inside. We can even replace the contents of that section entirely, and still pick up on events from elements inside. That's why we call it event delegation: instead of attaching listeners directly to the elements themselves, which might come and go, we "delegate" to a single listener higher in the tree, which then fires the event in the correct context.

So for our delegation utility function, we need a few things to happen:

We'll use the closest() method on an element to walk up the tree from the event target, and contains() to make sure we didn't accidentally escape our container. Here's our delegation function:

var delegate = function(container, selector, event, listener) {
  container.addEventListener(event, function(e) {
    // did this trigger on an element matching our selector?
    var matched = e.target.closest(selector);
    if (matched && container.contains(matched)) {
      // call listener, with "this" set to the target element
      listener.call(matched, e);
    }
  });
};

//in practice, against the HTML above:
var container = $.one(".primary");
delegate(container, "button.event-target", "click", function() {
  // when clicked, we should see the button on the console
  console.log("clicked!", this);
});

Once this listener is registered, we can blow away the existing contents of the primary section, or add new buttons, and our listener will still work whenever a button with a class of "event-target" is clicked inside. If you've ever run into problems with "vanishing" event listeners, delegation is a powerful tool—and now it's ours, without having to load all of jQuery just for a few functions.

Wrapping up

All told, our jQuery replacement is about 20 lines of code, not all of which we'll need on every project, and yet we're able to find multiple elements (or just one), search up the tree for an arbitrary selector, and delegate event listeners with ease. For modifying classes or styles, registering events, and other basic DOM functions, we just use the browser's built-in functionality. It may be a little more verbose, but it's much lighter and faster.

That said, are there times when we should use jQuery? Absolutely. Our goal isn't to get rid of big libraries, just to only use them when we really need them. My rule of thumb is that if there are three or more jQuery features that would make your project substantially easier, you should go ahead and include it. One of those features might also be the normalization that jQuery still does for cross-browser hiccups, like automatically using prefixed styles in browsers that need them. So if you were working on something that involves Promises, JSONP, and setting lots of newer CSS properties, jQuery might make sense. Use your judgement!

At NPR, we keep these jQuery-lite functions in three or four small library files, and include them as needed for any given project. After a while, it becomes second-nature. But at first, using the $() variable name might be confusing if you're used to jQuery. If so, it might make sense to import that function as qsa() (for "querySelectorAll" instead). However, for the rest of this book, whenever you see a $(), it'll mean this function, and not the original jQuery. Keep an eye out!