In this tutorial we’re going to build a jQuery-esque library from scratch. We’ll be targeting the latest browsers (IE9+) so we can make use of the some of the features that ECMAScript 5 provides out of the box.
How to Build a DOM Library
Collections & functions
In JavaScript we can store data in arrays and objects, and combinations of those. A common data structure is a collection. A collection is an array of objects, for example:
var users = [ {username: 'jonny89', age: 25, active: 1}, {username: 'mikeyfan', age: 42, active: 0}, {username: 'rogerp', age: 13, active: 1} ];
JavaScript has a few built-in methods to loop arrays that are very useful for collections:
// Filter collections by any criteria var adults = users.filter(function(user) { return user.age > 18; }); //^[{username:'jonny89', age:25, active:1}, // {username:'mikeyfan', age:42, active:0}] var active = users.filter(function(user) { return user.active; }); //^[{username:'jonny89', age:25, active:1}, // {username:'rogerp', age:13, active:1}] // Extract info from the collection var usernames = users.map(function(user) { return user.username; }); //^['jonny89','mikeyfan','rogerp'] var ages = users.map(function(user) { return user.age; }); //^[25,42,13]
Since these operations are so common, we can create higher-order helper functions to work with collections. A higher-order function is a function that takes other functions as input, or returns a function as output, for example the dot
helper below lets you extract a particular property from each item in the collection; we can use it with map
to get the results as an array:
// Higher-order helper function function dot(s) { return function(x) { //<-- returns a function return x[s]; }; }; var usernames = users.map(dot('username')); //^['jonny89','mikeyfan','rogerp']
Functional programming lets us work at a very high level of abstraction because functions are objects with methods and properties that can be passed around just like any other object.
The next helper we need work with functions is compose
. Composition of functions lets us create new functions from other functions and execute them in a sequence; it makes nested calls nicer. Composition is usually implemented right to left, but we’re going to implement it left to right so we can read it in the order we’d expect:
function compose(/*fns...*/) { return [].reduce.call(arguments, function(f,g){ return function() { return g(f.apply(this, arguments)); }; }); } function add1(x){ return x + 1 } function by2(x){ return x * 2 } function square(x){ return x * x } // Nested var result = add1(by2(square(2))); //=> 9 // Composition var comp = compose(square, by2, add1); var result = comp(2); //=> 9
Querying the DOM
The DOM API is known to be inconsistent, especially in old browsers, but we’re not targeting those. In modern browsers all we really need is querySelectorAll. DOM methods return pseudo-arrays of elements, not real arrays; they are objects similar to the arguments
object, in that they can be looped and accessed but they don’t have all the useful methods that arrays have. We need a simple helper to convert those objects to real arrays so we can treat them as collections:
function toArray(x) { // Not an array-like object if (!x.length || typeof x != 'object') { return x; } return Array.prototype.slice.call(x); }
Now we can query the DOM comfortably:
function query(sel, el) { return toArray((el||document).querySelectorAll(sel)); } // Querying the document var els = query('ul li'); // Querying another element var els = query('p', document.body);
Building the library
Let’s begin by wrapping our code in an IIFE (Immediately Invoked Function Expression), to avoid leaking variables to the global scope, then assigning the result to window.$
so we can use our library just like jQuery:
window.$ = (function(){ // code here }());
The constructor function
First we need a constructor to create new instances of our super powered DOM object, lets call it Dom
. The constructor will take one argument, a selector or an element. jQuery’s constructor function is huge as it deals with elements, selectors, arrays, HTML, etc. but we’re trying to keep it simple. Then we’ll setup the length and cache the first collection, as we’ll be using it later:
function Dom(sel) { this.el = typeof sel == 'string' // a selector ? query(sel) // an element or pseudo array of elements : [].concat(toArray(sel)); this.first = this.el; this.length = this.el.length; }
Next, let’s create a shortcut for our constructor so we can call it without using the new
keyword. We return this function from the IIFE to make it globally accessible:
window.$ = (function(){ function Dom(sel){ ... } // Call constructor without `new` function $(sel) { return new Dom(sel); } return $; // make it global }());
Now we can use $
from the outside to query the DOM.
When we log this to the console we will get a Dom
object, from our constructor:
var list = $('li'); //^ Dom {el: Array[3], first: Array[3], length: 3}
We can access the elements array with list.el
or a single element by index using list.el[n]
.
A strong foundation
Let’s simplify the way we get elements out of the collection. We’re going to create a public get
method that will return the array of elements or a single element by index:
Dom.prototype = { // Get element(s) get: function(idx) { return idx == null ? this.el : this.el[idx]; } };
Now we can get items like so:
list.get(); //=> [li,li,li] list.get(1); //=> li
One of the simplest DOM methods to implement is parent
. The parent
method returns a collection with the immediate parent of each element on the previous collection. We can use the native map
with our dot
helper to extract the parentNode
of each element:
parent: function() { // Modify previous collection this.el = this.el.map(dot('parentNode')); // Adjust length this.length = this.el.length; return this; // chain }
This works, but there is one problem: if two or more elements have the same parent then we would have duplicate elements in our new collection. For example:
$('li').parent().get(); //=> [ul,ul,ul]
We only need unique elements:
function unique(xs) { return xs.filter(function(x, idx) { return xs.indexOf(x) == idx; }); }
You can find other similar helpers on StackOverflow.
With our unique
helper we can update the parent
method to filter out duplicates:
parent: function() { // Modify previous collection this.el = unique(this.el.map(dot('parentNode')));
With this technique we could implement other similar methods that extract a single property from each element in the collection, like next
and prev
:
this.el.map(dot('nextElementSibling')) this.el.map(dot('previousElementSibling'))
But there’s a pattern that has to be repeated many times: assigning the new collection, updating the length, and returning the instance for chaining. We can abstract these steps into a method called _update
. We prefix it with an underscore because it’s meant for internal use:
_update: function(result) { this.el = [].concat(result); // make sure it's an array this.length = this.el.length; return this; }
Then we need to update the parent
method:
parent: function() { return this._update(unique(this.el.map(dot('parentNode')))); }
This works, but it looks a bit funky, and we’d still have to repeat unique(this.el.map(...))
for every method. Let’s abstract this further by creating our own map
method which will take a function and apply it to each element in the collection, then filter out the duplicates and return an updated collection:
map: function(f) { var result = unique(this.el.map(f)); return this._update(result); }
Now our parent
method will look more concise:
parent: function() { return this.map(dot('parentNode')); }
The next
and prev
methods can be implemented very easily with map
:
next: function() { return this.map(dot('nextElementSibling')); }, prev: function() { return this.map(dot('previousElementSibling')); }
Implementing children
requires a bit more work. Let’s try implementing it as we did with the other methods:
children: function() { return this.map(dot('children')); }
But when we run this method on a collection we get unexpected results:
$('ul, div').children(); //^ [HTMLCollection[3], HTMLCollection[1]]
What happens is that children
returns a pseudo-array but we need a real array. We need to modify our map
method so it makes sure that whatever gets mapped is an array. Naively we could simply pass a callback and call toArray
inside on each element after applying the function, for example:
map: function(f) { var result = unique(this.el.map(function(){ return toArray(f.apply(this, arguments)); })); return this._update(result); }
But this is definitely not pretty. In fact, we already have a helper to get around this ugly syntax, remember compose
?
map: function(f) { var result = unique(this.el.map(compose(f, toArray))); return this._update(result); }
Good, now we get arrays:
$('ul, div').children(); //=> [Array[3], Array[1]]
This is not exactly what we want though. What we really need is a single array of elements, not a nested array. We must make sure the resulting collection is always flattened. The flatten
helper solves this issue:
// Simple one level flatten function flatten(xs) { return Array.prototype.concat.apply([],xs); }
Let’s update the map
method to flatten the collection, and break it into two steps for readability this time:
map: function(f) { var result = this.el.map(compose(f, toArray)); result = unique(flatten(result)); return this._update(result); }
As you can see now we get a single array of elements:
$('ul, div').children(); //=> [li, li, li, p]
Adding complexity
Now that we have a strong foundation we can start implementing more complicated DOM methods.
Let’s begin with parents
. The parents
method returns a new collection with all the parents of each element in the previous collection. The difference between parent
and parents
is that the former only grabs the first parent, while the later grabs all parents in the hierarchy. In jQuery most methods also accept a selector to filter the collection further. We’ll see a possible implementation in the next section but for now lets keep it simple.
We can no longer use the dot
helper to implement parents
as we need to loop for as long as there are parents in the hierarchy. The implementation is slightly more complicated:
parents: function() { return this.map(function(x) { var result = []; while (x = x.parentNode) { result.push(x); } return result; }); }
Again, this looks ok, but it seems like we’d have to repeat this pattern to implement methods like nextAll
or prevAll
, which do the same thing with a different property. We want to abstract this into a higher-order function just as we did with dot
at the beginning. Lets call our helper loop
, as that’s what we’re doing:
function loop(s) { return function(x) { var result = []; while (x = x[s]) { result.push(x); } return result; }; }
Now we can update the parents
method to one pretty line:
parents: function() { return this.map(loop('parentNode')); }
The nextAll
and prevAll
methods look as you’d imagine:
nextAll: function() { return this.map(loop('nextElementSibling')); }, prevAll: function() { return this.map(loop('previousElementSibling')); }
Let’s think about siblings
. We want to grab all the previous siblings and all the next siblings for each element in the collection. Again, we cannot simply use dot
or loop
as we’re looping two different properties of a single element. A premature implementation would be to simply use the methods that we already have and concatenate the results, for example:
siblings: function() { var prev = $(this.el).prevAll(); var next = $(this.el).nextAll(); return this._update(prev.el.concat(next.el)); }
This works, but we can do better. Instead of creating new instances we need to think at the level of collections. What we really want is to join the results of two operations, looping the previous elements and looping the next elements. We already have a loop
helper to do this, but we need a helper to join two of these results. The join
helper is a higher-order function that will take two functions (operations) and join the outputs into an array:
function join(f,g) { return function() { var as = arguments; return f.apply(this, as).concat(g.apply(this, as)); }; }
Now we can update siblings
to avoid creating new instances, thus improving performance:
siblings: function() { var prev = loop('previousElementSibling'); var next = loop('nextElementSibling'); return this.map(join(prev, next));
Other useful methods
Our library is already very capable of doing all sorts of DOM operations but still can’t filter collections using a selector like in jQuery. Instead of updating each and every function to take an extra argument and checking the type of it, we can add a new method filter
that can be chained after any operation to get a new filtered collection.
Modern browsers provide some support for filtering elements via matches or matchesSelector
, but they are in most cases prefixed, and lead to ugly code and usage of polyfills. A common workaround is to use querySelectorAll
on the parent element of each element in the collection and check if the elements queried by the selector match the current element:
filter: function(sel) { var result = this.el.filter(function(x) { return query(sel, x.parentNode).indexOf(x) > -1; }); return this._update(flatten(result)); }
This implementation is simple enough for most cases, but you can try matchesSelector
to improve performance if needed.
With filter
we can now do this:
$('li').children().filter('a').get(); //=> [a,a,a]
Let’s implement two other useful jQuery functions, end
and eq
. The end
method returns the first collection in the chain, this is why we cached this.first
in the constructor. The eq
method returns a new collection out of a single element in the previous collection:
end: function() { return this._update(this.first); }, eq: function(idx) { return this._update(this.el[idx]); }
Other methods that grab a single property from an element can be implemented using dot
as we know. That includes text
, html
, val
, and even attr
can be implemented this way.
Notes
We deal with elements only, not text nodes. This limitation isn’t much of a problem if you have control over your HTML.
You can use map
on any collection and pass your own callback:
$('li').map(function(element) { var result = []; // ... return result; });
Conclusion
Querying the DOM with jQuery might seem like magic but it all comes down to working with collections as you’ve learned. Of course jQuery does much more than this, it does AJAX, promises, animations and provides many useful helpers. But if you just want to query the DOM and know what you’re doing as well as being able to extend its core easily, then you might not need jQuery after all.
Share your ideas and comments below.