Handling Images in JavaScript

 — 18 Min Read

When I started redesigning this site, I wanted a simple way to process images, covering a few basic needs. While there are various methods being formed for handling responsive images and display density, there is currently no prescribed approach. This site has no server code, so JavaScript was the obvious choice.

My requirements were:

I wrote a function which passes one or more imgs through a switch and creates a new Image object for each, appending the desired attributes, before replacing the original with a new img.

I'll go through each stage of imageHandler() in detail before linking to the full source but, first, let's take a look at the markup. This is what an image looks like:

<img src="image.png">

To have that image lazy-load, do this instead:

<img src="{{ site.lazy }}" data-src="image.png">

site.lazy is a Liquid variable I have defined in Jekyll's config.yml, which outputs the smallest possible Base64-encoded .gif, preventing the image from returning a 404 before JavaScript runs:

data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7

If you are not using a site generator or CMS, you may have to manually enter this string each time.

While the image loads in the background, it is nice to display a fallback colour:

<img data-fallback="#f33">

Lastly, if @2x or @3x versions of the image are available, we can flag that too. I've called them retina and plus:

<img data-retina data-plus>

Any combination of these attributes and properties can be used. The end result might look something like this:

<img src="{{ site.lazy }}" data-src="image.png" data-retina data-plus data-fallback="#f33">

The imageHandler() function contains a switch with two cases; 'fallback'—called on the DOMContentLoaded—and 'parse', which is called on window.onload.

function imageHandler(action, image, callback) {
    switch (action) {
        case 'fallback':
            
        break;
        case 'parse':
            
        break;
    }
};

'fallback' is simple; it conditions the existence of data-fallback on an img and, if present, sets its value as the background-color:

var fallback = image.hasAttribute('data-fallback');

if (fallback) {
    image.style.backgroundColor = fallback;
}

There is a little more to case 'parse'. First, we prepare a new attributes object and a new image object:

var attrs = new Object();
var newImage = new Image();

Then, we take the original images' attributes and place them into the attrs object we just defined:

for (var i = 0, nodes = image.attributes; i < nodes.length; i++) {
    attrs[nodes[i].nodeName] = nodes[i].nodeValue;
}

Now we have everything we need for JavaScript to do its work. First, lazy-loading:

if ('data-src' in attrs) {
    attrs.src = attrs['data-src'];
}

This will replace the src value with the original data-src attribute we defined in markup.

Next, condition the display's pixel density and define a src for the new image:

var path = attrs.src.substring(0, attrs.src.lastIndexOf('.'));
var extension = attrs.src.split('.').pop();

if (window.devicePixelRatio > 2 && 'data-plus' in attrs) {
    newImage.src = path + '@3x.' + extension;
} else if (window.devicePixelRatio > 1 && 'data-retina' in attrs) {
    newImage.src = path + '@2x.' + extension;
} else {
    newImage.src = attrs.src;
}

Note: this assumes your images are named with the following convention:

As you can see, if window.devicePixelRatio is not greater than 1, we simply use the src value which may or may not have been lazy-loaded.

After that, it is important to remove the attributes that are no longer needed so that anything else (such as the alt attribute) can be preserved and restored:

var garbage = ['src', 'data-src', 'data-retina', 'data-plus', 'data-fallback', 'style'];

for (var i = 0; i < garbage.length; i++) {
    delete attrs[garbage[i]];
}

for (var attr in attrs) {
    newImage.setAttribute(attr, attrs[attr]);
}

The new Image is now ready. Once it has loaded, we can swap it in for the original:

newImage.onload = function() {
    image.parentNode.insertBefore(newImage, image);
    image.parentNode.removeChild(image);

    if (callback) {
        callback();
    }
};

The reason for going through the process of building a new image rather than simply manipulating the attributes of the original is that, with this approach, it is possible to fire a callback once the image loads. For instance, you may want to display some form of activity-indication while the image is loading and remove it onload.

Lastly, we need to call the function on the relevant events:

document.addEventListener('DOMContentLoaded', function() {

    forEach(document.querySelectorAll('img[data-fallback]'), function(index, image) {
        imageHandler('fallback', image);
    });

}, false);

window.addEventListener('load', function() {

    forEach(document.querySelectorAll('img'), function(index, image) {
        imageHandler('parse', image);
    });

}, false);

Note: in this example I am using a custom forEach method to avoid NodeList hacks. More on that here. Feel free to loop over imgs in whichever way you choose.

The full source of the imageHandler() function is available as a Gist here.

This is how every image on 51bits.com is handled. Some images lazy-load, others do not. Some have @3x support, others maybe only @2x. It is not perfect but as there is yet to be a silver bullet for handling images on the web, this fits my needs so I feel it is worth sharing. I'm no JavaScript master so there could be better ways to do this, but it works for me.

Known issues and room for improvement: