Writing your own jQuery

Aug 14, 2014 in jQuery, Javascript, Interview

Sometimes during Javascript interview I ask candidates about jQuery internals. Nothing really crazy or too complicated, but I believe that a programmer with sufficient knowledge of the language will not have problems understanding the jQuery "magic".

In the next few sections I will cover main concepts you can use to build own simple lightweight "jQuery". Of course it's going to be very basic and simple library, but it will perfectly illustrate the idea. To avoid confusion let's call it ion - something small, tiny and not associated with jQuery and $ sign.

1. "ion" constructor

The very first thing to understand is that jQuery's $ is nothing but a function. It has a fancy name, but still. We start implementation with a usual IIFE:

(function(window, undefined) {

    function ion(selector) {
        if (!(this instanceof ion)) {
            return new ion(selector);
        }
    };

    ion.fn = ion.prototype;
    window.ion = ion;

})(window);

This $ function does a lot of things depending on passed parameters, but we can narrow them down to several main cases:

  1. Single function parameter. If the function is passed into $ function it will be executed on DOM ready (DOMContentLoaded) event, or immediately if this event has already occurred.

  2. String parameter. String argument is considered either a CSS selector or HTML string to construct a new DOM object.

  3. HTMLElement. If HTMLElement is passed, it's wrapped into jQuery instance object, resulting in jQuery collection of one element.

  4. NodeList. Node list is treated the same as single Node element above. The only difference is that returned jQuery collection includes multiple nodes.

  5. The function also ensures that it is invoked as a constructor. This is important because we need to work with its prototype.

Now when we have outlined main behavior ion must have, let's see how it can be implemented.

2. Handle DOMContentLoaded event

In order to support function as argument we will store it in the stack of callbacks to be invoked on DOM ready event. It can be written like this:

(function(window, undefined) {

    // Handle DOMContentLoaded event
    var domReadyStack = [];

    function handleDOMReady(fn) {
        return document.readyState === 'complete' ? fn.call(document) : domReadyStack.push(fn);
    }

    document.addEventListener('DOMContentLoaded', function onDOMReady() {
        document.removeEventListener('DOMContentLoaded', onDOMReady);
        while (domReadyStack.length) {
            domReadyStack.shift().call(document);
        }
    });

    // ion constructor
    function ion(selector) {

        if (!(this instanceof ion)) {
            return new ion(selector);
        }

        if (typeof selector === 'function') {
            return handleDOMReady(selector);
        }
    }

    ion.fn = ion.prototype;
    window.ion = ion;

})(window);

Similarly to jQuery we can use it this way:

ion(function() {
    console.log('DOM is ready!');
});

3. CSS selectors/creating HTML elements

Now we come to the essence of the library: DOM elements selection and creation. Retrieving DOM elements is pretty easy if we don't have to support older browsers (we don't :), so we can simply use querySelectorAll method for this. If selector is already a DOM element or NodeList then we only need to set them to nodes array for internal use.

(function(window, undefined) {

    // Handle DOMContentLoaded event
    // ...

    // ion constructor
    function ion(selector) {

        // ...

        // Number of elements in collection
        this.length = 0;

        // Nodes collection array
        this.nodes  = [];

        // HTMLElements and NodeLists are wrapped in nodes array
        if (selector instanceof HTMLElement || selector instanceof NodeList) {
            this.nodes = selector.length > 1 ? [].slice.call(selector) : [selector];
        }
        else if (typeof selector === 'string') {
            if (selector[0] === '<' && selector[selector.length - 1] === ">") {
                // Create DOM elements
                this.nodes = [createNode(selector)];
            }
            else {
                // Query DOM
                this.nodes = [].slice.call(document.querySelectorAll(selector));
            }
        }

        if (this.nodes.length) {
            this.length = this.nodes.length;
            for (var i = 0; i < this.nodes.length; i++) {
                this[i] = this.nodes[i];
            }
        }
    }

    function createNode(html) {
        var div = document.createElement('div');
        div.innerHTML = html;
        return div.firstChild;
    }

    ion.fn = ion.prototype;
    window.ion = ion;

})(window);

Note how HTMLElement's and NodeList's are converted to plain arrays and assigned to own property nodes of the current ion instance as well as individual properties of it. It allows to access specific DOM elements in familiar way, like: ion(".post")[0].

If selector is a string with the first character < and the last >, then such string is considered an HTML and is used to create a new DOM element. Note that for this purposed I used pretty naive approach with innerHTML. It's not ideal because of several reasons, one of them being is that browser will start downloading media content such as images or scripts immediately. But for the sake of this simple library it is not a problem.

Take a look at some demonstration: Demo 1.

Now let's see what ion can do so far:

// 1. Run code on DOM ready
ion(function() {
    console.log('DOM is ready');
});

// 2. Retrieve elements by CSS selector
var $oddLi = ion('.item-list > li:nth-child(odd)');

// 3. Create new DOM elements
var $newDiv = ion('<div class="profile"></div>');

All right, now when we have this basics, let's create actual methods we know from jQuery.

4. Extending "ion" prototype

This is fun part. The reason that jQuery is so cool is that it offers tons of useful methods, which are also very convenient to use due to method chaining. I'm going to show how easy it is to extend our simple library with this functionality. Of course it doesn't make sense to reimplement all the methods jQuery has, just few of them are going to be a good example.

Let's start with each method to iterate all the elements in the set. All the instance methods will be defined on ion prototype:

ion.fn.each = function(callback) {
    for (var i = 0; i < this.length; i++) {
        callback.call(this[i], this, i);
    }
    return this;
};

Pretty easy, right? Now we can use each method for other methods. Here is how addClass and text methods could look like:

ion.fn.addClass = function(classes) {
    return this.each(function() {
        this.className += ' ' + classes;
    });
};

ion.fn.text = function(str) {
    if (str) {
        return this.each(function() {
            this.innerText = str;
        });
    }
    return this.length && this[0].innerText;
};

Note how these methods return this instance. This is needed for method chaining, so you can write concise expressions like you are used to doing with jQuery:

ion('.time').addClass('now').text(Date.now());

Take a look at this Demo 2.

Similarly let's also implement one of the most important methods on. Our simplified version will allow as to bind DOM events, and is going to be built on top of addEventListener method. It will not support event delegation, but it's really not hard to add. So the implementation:

ion.fn.on = function(name, handler) {
    return this.each(function() {
        this.addEventListener(name, handler, false);
    });
};

Yes, that simple. Of course this is a very primitive implementation, lacking many features, but we are not going to reinvent jQuery.

Now following patterns above we can create any method our library may need. Finally, complete code for this mini-library:

(function(window, undefined) {

    // Handle DOMContentLoaded event
    var domReadyStack = [];

    function handleDOMReady(fn) {
        return document.readyState === 'complete' ? fn.call(document) : domReadyStack.push(fn);
    }

    document.addEventListener('DOMContentLoaded', function onDOMReady() {
        document.removeEventListener('DOMContentLoaded', onDOMReady);
        while (domReadyStack.length) {
            domReadyStack.shift().call(document);
        }
    });

    // ion constructor
    function ion(selector) {

        if (!(this instanceof ion)) {
            return new ion(selector);
        }

        if (typeof selector === 'function') {
            return handleDOMReady(selector);
        }

        // Number of elements in collection
        this.length = 0;

        // Nodes collection array
        this.nodes = [];

        // HTMLElements and NodeLists are wrapped in nodes array
        if (selector instanceof HTMLElement || selector instanceof NodeList) {
            this.nodes = selector.length > 1 ? [].slice.call(selector) : [selector];
        } else if (typeof selector === 'string') {
            if (selector[0] === '<' && selector[selector.length - 1] === ">") {
                // Create DOM elements
                this.nodes = [createNode(selector)];
            } else {
                // Query DOM
                this.nodes = [].slice.call(document.querySelectorAll(selector));
            }
        }

        if (this.nodes.length) {
            this.length = this.nodes.length;
            for (var i = 0; i < this.nodes.length; i++) {
                this[i] = this.nodes[i];
            }
        }
    }

    function createNode(html) {
        var div = document.createElement('div');
        div.innerHTML = html;
        return div.firstChild;
    }


    // Methods
    ion.fn = ion.prototype;

    ion.fn.each = function(callback) {
        for (var i = 0; i < this.length; i++) {
            callback.call(this[i], this, i);
        }
        return this;
    };

    ion.fn.addClass = function(classes) {
        return this.each(function() {
            this.className += ' ' + classes;
        });
    };

    ion.fn.removeClass = function(className) {
        return this.each(function() {
            this.className = this.className.replace(new RegExp('\\b' + className + '\\b', 'g'), '');
        });
    };

    ion.fn.text = function(str) {
        if (str) {
            return this.each(function() {
                this.innerText = str;
            });
        }
        return this.length && this[0].innerText;
    };

    ion.fn.on = function(name, handler) {
        return this.each(function() {
            this.addEventListener(name, handler, false);
        });
    };

    window.ion = ion;

})(window);

Take a look at this demonstration of the ion.

Conclusion

As you can see writing a little jQuery-like something can be fun. Of course I don't mean, anyone should try to replace jQuery with such a primitive hack like described in this article, after all jQuery is high quality tool, optimized and is carefully tested to work in many browsers. But I think that it can be a nice workout and also an interesting problem for the interview, to see if candidate actually understands how ultimately libraries like jQuery operate internally.

I will be very thankful for comments and corrections. Stay tuned!

comments powered by Disqus