How to make a JQuery

Learn to live without jQuery by learning how to clone it

jQuery is one of the earliest libraries every web developer learns, and often is the first experience with programming of any sort someone has. It provides a very safe cushion between a developer and the rough edges of web development. But, it can also obscure learning Javascript itself and learning what web APIs are capable of without the abstraction over them that jQuery adds.

jQuery came about at a time when it was very much needed. Limitations in browsers and differences between them created enormous hardships for developers. These days, the landscape is very different, and everyone should consider withholding on adding jQuery to their projects until absolutely necessary. Forgoing it encourages you to learn the Javascript language on its own, not just as a tool to employ one massive library. You will be exposed to the native APIs of the web, to better understand the things you’re doing. This improved understanding gives you a chance to be more directly exposed to new and changing web standards.

Let’s learn to recreate the most helpful parts of jQuery piece by piece. This exercise will help you learn what it actually does under the hood. When you know how those features work, you can find ways to avoid using jQuery for the most common cases.

Selecting Elements on the Page

The first and most prominent jQuery feature is selecting one or several elements from the page based on CSS selectors. When jQuery was first dropped on our laps, this was a mildly revolutionary ability to easily locate a piece of your page by a reliable, understandable address. In jQuery, selection looks like this:

$('#some-element')

The power of the simple jQuery selection has since been adapted into a standard new pair of document methods: querySelector() and querySelectorAll(). These take CSS selectors like jQuery and give you the first or all matching elements in an array, but that array isn’t as powerful as a jQuery set, so let’s replicate what jQuery does by smartening up the results a bit.

Simply wrapping querySelectorAll() is more than trivial. We'll call our little jQuery clone njq(), short for "Not jQuery" and use it the way you would use $().

function njq(selector) {
    return document.querySelectorAll(selector)
}

And now we can use njq() just like jQuery for selections.

njq('#some-element')

But, of course, jQuery gives us a lot more than this, so a simple wrapper won't do. To really match its power we need to add a few things:

  • Default to an empty set of elements
  • Wrap the original HTML element objects if we're given one
  • Wrap the results such that we can attach behaviors to them

These simple additions give us a more robust example of what jQuery can do.

var empty = $() // getting an empty set
var html = $('<h2>test</h2>') // from an HTML snippet
var wrapped = $(an_html_element) // wrapping an HTML Element object
wrapped.hide() // using attached behaviors, in this case calling hide()

So let's add these abilities. We'll implement the empty set version, wrapping Element objects, accepting arrays of elements, and attaching extra methods. We'll start by adding one of the most useful jQuery methods: the each() method used to loop over all the elements it holds.

function njq(arg) {
    let results
    if (typeof arg === 'undefined') {
        results = []
    } else if (arg instanceof Element) {
        results = [arg]
    } else if (typeof arg === 'string') {
        // If the argument looks like HTML, parse it into a DOM fragment
        if (arg.startsWith('<')) {
            let fragment = document.createRange().createContextualFragment(arg)
            results = [fragment]
        } else {
            // Convert the NodeList from querySelectorAll into a proper Array
            results = Array.prototype.slice.call(document.querySelectorAll(arg))
        }
    } else {
        // Assume an array-like argument and convert to an actual array
        results = Array.prototype.slice.call(arg)
    }
    results.__proto__ = njq.methods
    return results
}

njq.methods = {
    each: function(func) {
        Array.prototype.forEach.call(this, func)
    },
}

This is a good foundation, but jQuery selection has a few other required helpers we need to consider our version even close to complete. To be more complete, we have to add helpers for search both up and down the HTML tree from the elements in a result set.

Walking down the tree is done with the find() method that selects within the children of the results. Here we learn a second form of querySelectorAll(), which is called on an individual element, not an entire document, and only selects within its children. Like so:

var list = $('ul')
var items = list.find('li')

The only extra work we have left to do is to ensure we don't add any duplicates to the result set, by tracking which elements we've already added as we call querySelectorAll() on each element in the original elements and combine all their results together.

njp.methods.find = function(selector) {
    var seen = new Set()
    var results = njq()
    this.each((el) => {
        Array.prototype.forEach.call(el.querySelectorAll(selector), (child) => {
            if (!seen.has(child)) {
                seen.add(child)
                results.push(child)
            }
        })
    })
    return results
}

Now we can use find() in our own version:

var list = njq('ul')
var items = list.find('li')

Searching down the HTML tree was useful and straight forward, but we aren't complete if we can't do it in the reverse: searching up the tree from the original selection. This is where we'll clone jQuery's closest() method.

In jQuery, closest() helps when you already have an element, and want to find something up the tree in it. In this example, we find all the bold text in a page and then find what paragraph they're from:

var paragraphs_with_bold = $('b').closest('p')

Of course, multiple elements we have may have the same ancestors, so we need to handle duplicate results in this method, as we did before. We won't get much help from the DOM directly, so we walk up the chain of parent elements one at a time, looking for matches. The only help the DOM gives us here is Element.matches(selector), which tells us if a given element matches a CSS selector we're looking for. When we find matches we add them to our results. We stop searching immediately for each element's first match, because we're only looking for the "closest", after all.

njq.methods.closest = function(selector) {
    var closest = new Set()
    this.each((el) => {
        let curEl = el
        while (curEl.parentElement && !curEl.parentElement.matches(selector)) {
            curEl = curEl.parentElement
        }
        if (curEl.parentElement) {
            closest.add(curEl.parentElement)
        }
    })
    return njq(closest)
}

We've put the basic pieces of selection in place now. We can query the page for elements, and we can query those results to drill down or walk up the HTML tree for related elements. All of this is useful, and we can walk over our results with the each() method we started with.

var paragraphs_with_bold = njq('b').closest('p')

Basic Manipulations

We can't do very much with the results, yet, so let's add some of the first manipulation helpers everyone learned with jQuery: manipulating classes.

Manipulating classes means you can turn a class on or off for a whole set of elements, changing its styles, and often hiding or showing entire bits of the page. Here are our simple class helpers: addClass() and removeClass() will add or remove a single class from all the elements in the result set, toggleClass() will add the class to all the elements that don't already have it, while removing it from all the elements which presently do have the class.

The jQuery methods we're reimplementing work like this:

$('#submit').addClass('primary')
$('.message').removeClass('new')
$('#modal').toggleClass('shown')

Thankfully, the DOM's native APIs make all of these very simple. We'll use our existing each() method to walk over all the results, but manipulating the class in each of them is a simple call to methods on the elements' classList interface, a specialized array just for managing element classes.

njq.methods.toggleClass = function(className) {
    this.each((el) => {
        el.classList.toggle(className)
    })
}

njq.methods.addClass = function(className) {
    this.each((el) => {
        el.classList.add(className)
    })
}

njq.methods.removeClass = function(className) {
    this.each((el) => {
        el.classList.remove(className)
    })
}

Now we have a very simple jQuery clone that can walk around the DOM tree and do basic manipulations of classes to change the styling. This, by itself, has enough parts to be useful, but some times just adding or removing classes isn't enough. Some times you need to manipulate styles and other properties directly, so we're going to add a few more small manipulation utilities:

  • We want to change the text in elements
  • We want to swap out entire HTML bodies of elements
  • We want to inspect and change attributes on elements
  • We want to inspect and change CSS styles on elements

These are all simple operations with jQuery.

$('#message-box').text(new_message_text)
$('#page').html(new_content)

Changing the contents of an element directly, whether text or HTML, is as simple as a single attribute we'll wrap with our helpers: text() and html(), wrapping the innerText and innerHTML properties, specifically. Like nearly all of our methods we're building on top of each() to apply these operations to the whole set.

njq.methods.text = function(t) {
    this.each((el) => el.innerText = t)
}

njq.methods.html = function(t) {
    this.each((el) => el.innerHTML = t)
}

Now we'll start to get into methods that need to do multiple things. Setting the text or HTML is useful, but often reading it is useful, too. Many of our methods will follow this same pattern, so if a new value isn't provided, then instead we want to return the current value. Copying jQuery, when we read things we'll only read them from the first element in a set. If you need to read them from multiple elements, you can walk over them with each() to do that on your own.

var msg_text = $('#message').text()

These two methods are easily enhanced to add read versions:

njq.methods.text = function(t) {
    if (arguments.length === 0) {
        return this[0].innerText
    } else {
        this.each((el) => el.innerText = t)
    }
}

njq.methods.html = function(t) {
    if (arguments.length === 0) {
        return this[0].innerHTML
    } else {
        this.each((el) => el.innerHTML = t)
    }
}

Next, all elements have attributes and styles and we want helpers to read and manipulate those in our result sets. In jQuery, these are the attr() and css() helpers, and that's what we'll replicate in our version. First, the attribute helper.

$("img#greatphoto").attr("title", "Photo by Kelly Clark");

Just like our text() and html() helpers, we read the value from the first element in our set, but set the new value for all of them.

njq.methods.attr = function(name, value) {
    if (typeof value === 'undefined') {
        return this[0].getAttribute(name)
    } else {
        this.each((el) => el.setAttribute(name, value))
    }
}

Working with styles, we allow three different versions of the css() helper.

First, we allow reading the CSS property from the first element. Easy.

var fontSize = parseInt(njq('#message').css('font-size'))
njq.methods.css = function(style) {
    if (typeof style === 'string') {
        return getComputedStyle(this[0])[style]
    }
}

Second, we change the value if we get a new value passed as a second argument.

var fontSize = parseInt(njq('#message').css('font-size'))
if (fontSize > 20) {
    njq('#message').css('font-size', '20px')
}
njq.methods.css = function(style, value) {
    if (typeof style === 'string') {
        if (typeof value === 'undefined') {
            return getComputedStyle(this[0])[style]
        } else {
            this.each((el) => el.style[style] = value)
        }
    }
}

Finally, because it's very common you want to change multiple CSS properties, and probably at the same time, the css() helper will accept a hash-object mapping property names to new property values and set them all at once:

njq('.banner').css({
    'background-color': 'navyblue',
    'color': 'white',
    'font-size: 40px',
})
njq.methods.css = function(style, value) {
    if (typeof style === 'string') {
        if (typeof value === 'undefined') {
            return getComputedStyle(this[0])[style]
        } else {
            this.each((el) => el.style[style] = value)
        }
    } else {
        this.each((el) => Object.assign(el.style, style))
    }
}

Our jQuery clone is really shaping up. With it, we've replicated all these things jQuery does for us:

  • Selecting elements across a page
  • Selecting either descendents or ancestors of elements
  • Toggling, adding, or removing classes across a set of elements
  • Reading and modifying the attributes an element has
  • Reading and modifying the CSS properties an element has
  • Reading and changing the text contents of an element
  • Reading and changing the HTML contents of an element

That's a lot of helpful DOM manipulation! If we stopped here, this would already be useful.

Of course, we're going to continue adding more features to our little jQuery clone. Eventually we'll add more ways to manipulate the HTML in the page, before we come back to manipulation let's start adding support for events to let a user interact with the page.

Event Handling

Events in Javascript can come from a lot of sources. The kinds of events we're interested in are user interface events. The first event you probably care about is the click event, but we'll handle it just like any other.

$("#dataTable tbody tr").on("click", function() {
    console.log( $( this ).text() )
})

Like some of our other helpers, we're wrapping what is now a standard facility in the APIs the web defines to interact with a page. We're wrapping addEventListener(), the standard DOM API available on all elements to bind a function to be called when an event happens on that element. For example, if you bind a function to the click event of an image, the function will be called.

We might need some information about the event, so we're going to trigger our callback with this bound to the element you were listening to and we'll pass the Event object, which describes all about the event in question, as a parameter.

njq.methods.on = function(event, cb) {
    this.each((el) => {
        // addEventListener will invoke our callback
        // with two parameters: the element the event
        // comes from and the event object itself.
        el.addEventListener(event, cb)
    })
}

This is a useful start, but events can do so much more. First, before we make our event listening more powerful, let's make sure we can hit the undo button by adding a way to remove them.

var $test = njq("#test");

function handler1() {
    console.log("handler1")
    $test.off("click", handler2)
}

function handler2() {
    console.log("handler2")
}

$test.on("click", handler1);
$test.on("click", handler2);

The standard addEventListener() comes paired with removeEventListener(), which we can use since our event binding was simple:

njq.methods.off = function(event, cb) {
    this.each((el) => {
        el.removeEventListener(event, cb)
    })
}

Event Delegation

When your page is changing through interactions it can be difficult to maintain event bindings on the right elements, especially when those elements could move around, be removed, or even replaced. Delegation is a very useful way to bind event handlers not to a specific element, but to to a query of elements that changes with the contents of your page.

For example, you might want to let any <li> elements that get added to a list be removed when you click on them, but you want this to happen even when new items are added to the list after your event binding code ran.

<h3>Grocery List</h3>
<ul>
    <li>Apples</li>
    <li>Bread</li>
    <li>Peanut Butter</li>
</ul>
njq('ul').on('click', 'li', function(ev) {
    njq(ev.target).remove()
})

This very useful, but complicates our event binding a bit. Let's dive in to adding this feature.

First, we have to accept on() being called with either 2 or 3 arguments, with the 3 argument version accepting a delegation selector as the second argument. We can use Javascript's special arguments variable to make this straight forward.

njq.methods.on = function(event, cb) {
    let delegate, cb

    // When called with 2 args, accept 2nd arg as callback
    if (arguments.length === 2) {
        cb = arguments[1]
    // When called with 3 args, accept 2nd arg as delegate selector,
    // 3rd arg as callback
    } else {
        delegate = arguments[1]
        cb = arguments[2]
    }

    this.each((el) => {
        el.addEventListener(event, cb)
    })
}

Our event handler is still being invoked for every instance of the event. In order to implement delegation properly, we want to block the handler when the event didn't come from the right child element matching the delegation selector.

njq.methods.on = function(event, cb) {
    let delegate, cb

    // When called with 2 args, accept 2nd arg as callback
    if (arguments.length === 2) {
        cb = arguments[1]
    // When called with 3 args, accept 2nd arg as delegate selector,
    // 3rd arg as callback
    } else {
        delegate = arguments[1]
        cb = arguments[2]
    }

    this.each((el) => {
        el.addEventListener(event, function(ev) {
            // If this was a delegate event binding,
            // skip the event if the event target is not inside
            // the delegate selection.
            if (typeof delegate !== 'undefined') {
                if (!root.find(delegate).includes(ev.target)) {
                    return
                }
            }
            // Invoke the event handler with the event arguments
            cb.apply(this, arguments)
        }, cb, false)
    })
}

We've wrapped our event listener in a helper function, where we check the event target each time the event is triggered and only invoke our callback when it matches.

Advanced Manipulations

We have a good foundation now. We can find the elements we need in the structure of our page, modify properties of those elements like attributes and CSS styles, and respond to events from the user on the page.

Now that we've got that in place, we could start making larger manipulations of the page. We could start adding new elements, moving them around, or cloning them. These advanced manipulations will the final set of helpers we add to our library.

Append

One of the most useful operations is adding a new element to the end another. You might use this to add a new <li> to the end of a list, or add a new paragraph of text to an existing page.

There are a few ways we want to allow appending, and we'll add each one at a time.

First, we'll allow simply appending some text.

njq.methods.append = function(content) {
    if (typeof content === 'string') {
        this.each((el) => el.innerHTML += content)
    }
}

Then, we'll allow adding elements. These might come from queries our library has done on the page.

njq.methods.append = function(content) {
    if (typeof content === 'string') {
        this.each((el) => el.innerHTML += content)
    } else if (content instanceof Element) {
        this.each((el) => el.appendChild(content.cloneNode(true)))
    }
}

Finally, to make it easier to select elements and append them somewhere else, we'll accept an array of elements in addition to just one. Remember, our njq query objects are themselves arrays of elements.

njq.methods.append = function(content) {
    if (typeof content === 'string') {
        this.each((el) => el.innerHTML += content)
    } else if (content instanceof Element) {
        this.each((el) => el.appendChild(content.cloneNode(true)))
    } else if (content instanceof Array) {
        content.forEach((each) => this.append(each))
    }
}

Prepend

As long as we are adding to the end of elements, we'll want to add to the beginning as well. This is a nearly identical to the append() version.

njq.methods.prepend = function(content) {
    if (typeof content === 'string') {
        // We add the next text to the start of the element's inner HTML
        this.each((el) => el.innerHTML = content + el.innerHTML)
    } else if (content instanceof Element) {
        // We use insertBefore here instead of appendChild
        this.each((el) => el.parentNode.insertBefore(content.cloneNode(true), el))
    } else if (content instanceof Array) {
        content.forEach((each) => this.prepend(each))
    }
}

Replacement

jQuery offers two replacement methods, which work in opposite directions.

$('.left').replaceAll('.right')
$('.left').replaceWith('.right')

The first, replaceAll(), will use the elements from $('.left') and use those to replace everything from $('.right'). If you had this HTML:

<h2>Some Uninteresting Header Text</h2>
<p>A very important story to tell.</p>

you could run this to replace the tag entirely, not just its contents:

$('<h1>Some Exciting Header Text</h1>').replaceAll('h2')

and your HTML would now look like this:

<h1>Some Exciting Header Text</h1>
<p>A very important story to tell.</p>

The second, replaceWith(), does the opposite by using elements from $('.right') to replace everything in $('.left')

$('h2').replaceWith('<h1>Some Exciting Header Text</h1>')

So let's add these to "Not jQuery".

njq.methods.replaceWith = function(replacement) {
    let $replacement = njq(replacement)
    let combinedHTML = []
    $replacement.each((el) => {
        combinedHTML.push(el.innerHTML)
    })
    let fragment = document.createRange().createContextualFragment(combinedHTML)
    this.each((el) => {
        el.parentNode.replaceChild(fragment, el)
    })
}

njq.methods.replaceAll = function(target) {
    njq(target).replaceWith(this)
}

Since this is a little more complex than some of our simpler methods, let's step through how it works. First, notice that we only really implemented one of them, and the second simply re-uses that with the parameters reversed. Now, both of these replacement methods will replace the target with all of the elements from the source, so the first thing we do is extract a combined HTML of all those.

let $replacement = njq(replacement)
let combinedHTML = []
$replacement.each((el) => {
    combinedHTML.push(el.innerHTML)
})
let fragment = document.createRange().createContextualFragment(combinedHTML)

Now we can replace all the target elements with this new fragment that contains our new content. To replace an element, we have to ask its parent to replace the correct child, using replaceChild():

this.each((el) => {
    el.parentNode.replaceChild(fragment, el)
})

Clone

The last yet easiest to implement helper is a clone() method. Allowing us to copy elements, and all their children, will make other helpers more powerful by allowing us to either move or copy them. This can be combined with other helpers we've already added, so that you have control over prepend and append operations moving or copying the elements they move around.

njq.methods.clone = function() {
    return njq(Array.prototype.map.call(this, (el) => el.cloneNode(true)))
}

Now You Made a jQuery

We've replicated a lot of the things jQuery gives us out of the box. Our little jQuery clone is able to select elements on a page and relative to other elements via find() and closest(). Events can be handled with simple on(event, callback) bindings and more complex on(event, selector, callback) delegations. The contents, attributes, and styles of elements can be read and manipulated with text(), html(), attr(), and css(). We can even manipulate whole segments of the DOM tree with append(), prepend(), replaceAll() and replaceWith().

jQuery certainly offers a much deeper and wider toolbox of goodies. We weren't aiming to create a call-for-call 100% compatible replacement, just to understand what happens under the hood. If you learned anything from this exercise, learn that the tools you use are all transparent and can be learned from. They're all layers on an onion that you can peel back and learn from.

New Call-to-action
blog comments powered by Disqus
Times
Check

Success!

Times

You're already subscribed

Times