A DOM Manipulation Class in 100 Lines of JavaScript

By Krasimir Tsonev

If you build web applications you probably deal with the DOM a lot. Accessing and manipulating DOM elements is a common requiement of nearly every web application. Very often we collect information from different controls, we need to set values, change the content of div or span tags. Of course there are a lot of libraries that help handle these actions, with the most popular being jQuery, of course, which is the de factor standard. However, sometimes you don’t need everything that jQuery provides, so in this article we will take a look at how to build your own class for managing DOM elements.

The API

As developers we make decisions every day. I believe in the test-driven development and one of the things which I really like is the fact that it forces you to make design decisions before you start the actual coding. Along those lines, here is what I want the DOM management class’s API to look like in the end:

// returns DOM element
dom('.selector').el
// returns the value/content of the element
dom('.selector').val() 
// sets the value/content of the element
dom('.selector').val('value') 

This should cover most of the possible use cases. However it would be even better if we could manipulate several objects at once. And it would be great if we could generate a JavaScript object.

// generates an object containing DOM elements
dom({
    structure: {
        propA: '.selector',
        propB: '.selector'
    },
    propC: '.selector'
}) 

Once we have our elements stored we could easily execute the val method for all of them.

// retrieving the values of several DOM elements
dom({
    structure: {
        propA: '.selector',
        propB: '.selector'
    },
    propC: '.selector'
}).val()

This will be aneffective method for translating data from the DOM directly into a JavaScript object.

Now that we have an idea of what our API should look like, our class starts with the following code:

var dom = function(el) {
    var api = { el: null }
    api.val = function(value) {
        // ...
    }
    return api;
}

Scoping

It is clear that we are going to use methods like getElementById, querySelector or querySelectorAll. Typically, you might access the DOM like this:

var header = document.querySelector('.header');

What is really interesting here is that querySelector, for example, is not just a method of the document object, but also of any other DOM element. This means that we are able to run the query in specific context. For example:

<header>
    <p>Big</p>
</header>
<footer>
    <p>Small</p>
</footer>

var header = document.querySelector('header');
var footer = document.querySelector('footer');
console.log(header.querySelector('p').textContent); // Big
console.log(footer.querySelector('p').textContent); // Small

We are able to operate within specific part of the DOM tree and our class should support the passing of a scope. So, together with a selector it would be good if it accepts a parent element.

var dom = function(el, parent) {
    var api = { el: null }
    api.val = function(value) {
        // ...
    }
    return api;
}

Reaching the DOM element

As we said above, we are going to use querySelector and querySelectorAll to reach the DOM elements. Let’s create two shortcuts for these functions.

var qs = function(selector, parent) {
    parent = parent || document;
    return parent.querySelector(selector);
};
var qsa = function(selector, parent) {
    parent = parent || document;
    return parent.querySelectorAll(selector);
};

After that we should use the passed el argument. Normally this will be a string (selector) but we should also support:

  • A DOM element – the val method of the class will be pretty handy so we may need to use the class with already referenced element;
  • A JavaScript object – in order to create JavaScript object containing multiple DOM elements.

The following switch will cover both cases:

switch(typeof el) {
    case 'string':
        parent = parent && typeof parent === 'string' ? qs(parent) : parent;
        api.el = qs(el, parent);
    break;
    case 'object': 
        if(typeof el.nodeName != 'undefined') {
            api.el = el;
        } else {
            var loop = function(value, obj) {
                obj = obj || this;
                for(var prop in obj) {
                    if(typeof obj[prop].el != 'undefined') {
                        obj[prop] = obj[prop].val(value);
                    } else if(typeof obj[prop] == 'object') {
                        obj[prop] = loop(value, obj[prop]);
                    }
                }
                delete obj.val;
                return obj;
            }
            var res = { val: loop };
            for(var key in el) {
                res[key] = dom.apply(this, [el[key], parent]);
            }
            return res;
        }
    break;
}

The first case is executed if the developer passes a string. We prepare the parent and call the querySelector shortcut. The second part of the statement is for the cases where we have a DOM element sent or a JavaScript object. We are checking if the object has nodeName property, and, if it does, then we directly apply it as a value of the api.el property. If it doesn’t, then we go through all the parts of the object and initialize a class instance for every property. Here are some test cases involving the following markup:

<p>text</p>
<header>
    <p>Big</p>
</header>
<footer>
    <p>Small</p>
</footer>

Accessing the first paragraph:

dom('p').el

Accessing the paragraph in the header node:

dom('p', 'header').el

Passing a DOM element:

dom(document.querySelector('header')).el

Passing a JavaScript object:

var els = dom({
    footer: 'footer',
    paragraphs: {
        header: 'header p',
        footer: 'footer p'
    }
}))
// At the end we have again JavaScript object.
// It's properties are actually results
// of dom function execution. For example, to get
// the paragraph in the footer:
els.paragraphs.footer.el

Getting or setting the value of an element

The value of the form elements like input or select could be retrieved easily – we can use the value property of the element. We already have an access to the DOM element – it is stored in api.el. However, it is a little bit tricky when we are working with radio or check boxes. For the other HTML nodes like divs, sections or spans for example we need to get the value of the textContent property. If there is no textContent defined then innerHTML will produce similar results. Let’s use another switch statement:

api.val = function(value) {
    if(!this.el) return null;
    var set = !!value;
    var useValueProperty = function(value) {
        if(set) { this.el.value = value; return api; }
        else { return this.el.value; }
    }
    switch(this.el.nodeName.toLowerCase()) {
        case 'input':
        break;
        case 'textarea':
        break;
        case 'select':              
        break;
        default:
    }
    return set ? api : null;
} 

First of all we need to have api.el defined. The variable set is a boolean telling us if we are retrieving or setting the value of the element. There is a helper method defined for those elements which have .value property. The switch will contain the actual logic of the method. At the end we are returning the API itself in order to chain the methods of the class. Of course we are doing this only if we are using the function as a setter.

Let’s see how to handle the different types of elements. For example the input node:

case 'input':
    var type = this.el.getAttribute('type');
    if(type == 'radio' || type == 'checkbox') {
        var els = qsa('[name="' + this.el.getAttribute('name') + '"]', parent);
        var values = [];
        for(var i=0; i<els.length; i++) {
            if(set && els[i].checked && els[i].value !== value) {
                els[i].removeAttribute('checked');
            } else if(set && els[i].value === value) {
                els[i].setAttribute('checked', 'checked');
                els[i].checked = 'checked';
            } else if(els[i].checked) {
                values.push(els[i].value);
            }
        }
        if(!set) { return type == 'radio' ? values[0] : values; }
    } else {
        return useValueProperty.apply(this, [value]);
    }
break;

This is may be the most interesting case. There are two types of elements which need to be processed differently – radio and check boxes. These elements are grouped into sets and we need to keep this in mind. That’s why we are using querySelectorAll to fetch the whole group and find out which one is selected/checked. It’s even more complex, because a group of check boxes could have more then one value. The method above successfully handles all these situations.

The processing of a textarea element is pretty simple thanks to the helper we wrote above.

case 'textarea': 
    return useValueProperty.apply(this, [value]); 
break;

Here’s how we handle a drop down (select):

case 'select':
    if(set) {
        var options = qsa('option', this.el);
        for(var i=0; i<options.length; i++) {
            if(options[i].getAttribute('value') === value) {
                this.el.selectedIndex = i;
            } else {
                options[i].removeAttribute('selected');
            }
        }
    } else {
        return this.el.value;
    }
break;

And this will process everything else:

default: 
    if(set) {
        this.el.innerHTML = value;
    } else {
        if(typeof this.el.textContent != 'undefined') {
            return this.el.textContent;
        } else if(typeof this.el.innerText != 'undefined') {
            return typeof this.el.innerText;
        } else {
            return this.el.innerHTML;
        }
    }
break;

With these lines of code we have finished our val method. Here is a short HTML form and its corresponding test:

<form>
    <input type="text" value="sample text" />
    <input type="radio" name="options" value="A">
    <input type="radio" name="options" checked value="B">
    <select>
        <option value="10"></option>
        <option value="20"></option>
        <option value="30" selected></option>
    </select>
    <footer>version: 0.3</footer>
</form>

If we use the following code:

dom({
    name: '[type="text"]',
    data: {
        options: '[type="radio"]',
        count: 'select'
    },
    version: 'footer'
}, 'form').val();

We will get:

{
    data: {
        count: "30",
        options: "B"
    },
    name: "sample text",
    version: "version: 0.3"
}

This method could be really helpful if you want to translate a data from HTML form into JavaScript object. This is a pretty common task that many of us need to accomplish almost every day.

Final result

The finished class is only 100 lines of code but it still gives us what we need to access DOM elements and to get or set their value/content.

var dom = function(el, parent) {
    var api = { el: null }
    var qs = function(selector, parent) {
        parent = parent || document;
        return parent.querySelector(selector);
    };
    var qsa = function(selector, parent) {
        parent = parent || document;
        return parent.querySelectorAll(selector);
    };
    switch(typeof el) {
        case 'string':
            parent = parent && typeof parent === 'string' ? qs(parent) : parent;
            api.el = qs(el, parent);
        break;
        case 'object': 
            if(typeof el.nodeName != 'undefined') {
                api.el = el;
            } else {
                var loop = function(value, obj) {
                    obj = obj || this;
                    for(var prop in obj) {
                        if(typeof obj[prop].el != 'undefined') {
                            obj[prop] = obj[prop].val(value);
                        } else if(typeof obj[prop] == 'object') {
                            obj[prop] = loop(value, obj[prop]);
                        }
                    }
                    delete obj.val;
                    return obj;
                }
                var res = { val: loop };
                for(var key in el) {
                    res[key] = dom.apply(this, [el[key], parent]);
                }
                return res;
            }
        break;
    }
    api.val = function(value) {
        if(!this.el) return null;
        var set = !!value;
        var useValueProperty = function(value) {
            if(set) { this.el.value = value; return api; }
            else { return this.el.value; }
        }
        switch(this.el.nodeName.toLowerCase()) {
            case 'input':
                var type = this.el.getAttribute('type');
                if(type == 'radio' || type == 'checkbox') {
                    var els = qsa('[name="' + this.el.getAttribute('name') + '"]', parent);
                    var values = [];
                    for(var i=0; i<els.length; i++) {
                        if(set && els[i].checked && els[i].value !== value) {
                            els[i].removeAttribute('checked');
                        } else if(set && els[i].value === value) {
                            els[i].setAttribute('checked', 'checked');
                            els[i].checked = 'checked';
                        } else if(els[i].checked) {
                            values.push(els[i].value);
                        }
                    }
                    if(!set) { return type == 'radio' ? values[0] : values; }
                } else {
                    return useValueProperty.apply(this, [value]);
                }
            break;
            case 'textarea': 
                return useValueProperty.apply(this, [value]); 
            break;
            case 'select':
                if(set) {
                    var options = qsa('option', this.el);
                    for(var i=0; i<options.length; i++) {
                        if(options[i].getAttribute('value') === value) {
                            this.el.selectedIndex = i;
                        } else {
                            options[i].removeAttribute('selected');
                        }
                    }
                } else {
                    return this.el.value;
                }
            break;
            default: 
                if(set) {
                    this.el.innerHTML = value;
                } else {
                    if(typeof this.el.textContent != 'undefined') {
                        return this.el.textContent;
                    } else if(typeof this.el.innerText != 'undefined') {
                        return typeof this.el.innerText;
                    } else {
                        return this.el.innerHTML;
                    }
                }
            break;
        }
        return set ? api : null;
    }
    return api;
}

I’ve created a JSBin example that you can play with to see how the class works.

Summary

The class I discussed above is part of the AbsurdJS client-side components. The full documentation for the module could be found here. The aim of the code is not to replace jQuery or the dozens of popular libraries available for DOM access. The idea of the function is to be independent, to do only one thing and to do it well. Which is the main concept behind AbsurdJS and its build-in modules like the router or Ajax wrapper.

Previous

JXcore – A Node.JS Distribution with Multi-threading

Improving Speech Recognition in the Browser

Next

5 thoughts on “A DOM Manipulation Class in 100 Lines of JavaScript”

  1. Hi, very nice article, and I’m very impressed with the way the class works. Although, I would have gone with a different method of caching all elements and using selector functions to group these into result sets.
    A typical application that I am developing has several DOM queries, and even with separating my “select DOM element” code from my “process data” functionality, the code was fairly messy until I decided to cache all the frequent elements to arrays with their own attributes. that way I don’t go through the tedious process of fetching it from the document, and I know the app will be slightly faster since I can access some items directly. Your solution is quite nice for general-purpose use, and I will consider adding your solution to my codebase.

Comments are closed.