Echo.js, simple JavaScript image lazy loading Edit this page on GitHub

I'm currently working on a project for Intel's HTML5 Hub in which I require some image lazy-loading for an HTML5 showcase piece that's high in image content. After a quick Google search for an existing lazy-load solution there was yet another mass of outdated scripts or jQuery plugins that were too time consuming to search through or modify for the project - so I ended up writing my own.

Echo.js is probably as simple as image lazy loading gets, it's less than 1KB minified and is library agnostic (no jQuery/Zepto/other).

Lazy-loading works by only loading the assets needed when the elements 'would' be in view, which it'll get from the server for you upon request, which is automated by simply changing the image src attribute. This is also an asynchronous process which also benefits us.

Using Echo.js

Using Echo is really easy, just include an original image to be used as a placeholder, for the demo I am using a simple AJAX .gif spinner as a background image with a transparent .gif placeholder so the user will always see something is happening, but you can use whatever you like.

Here's the markup to specify the image source, which is literal so you'll be able to specify the full file path (even the full http:// if you like) which makes it easier when working with directories.

<img src="img/blank.gif" alt="" data-echo="img/album-1.jpg">

Just drop the script into your page before the closing </body> tag and let it do it's thing. For modern browsers I've used the DOMContentLoaded event incase you really need it in the <head>, which is a native 'DOM Ready', and a fallback to onload for IE7/8 if you need to go that far so all works nicely.

JavaScript

As always, I'll talk through the script for those interested in the behind the scenes working. Here's the full script:

window.echo = (function (window, document) {

  'use strict';

  /*
   * Constructor function
   */
  var Echo = function (elem) {
    this.elem = elem;
    this.render();
    this.listen();
  };

  /*
   * Images for echoing
   */
  var echoStore = [];
  
  /*
   * Element in viewport logic
   */
  var scrolledIntoView = function (element) {
    var coords = element.getBoundingClientRect();
    return ((coords.top >= 0 && coords.left >= 0 && coords.top) <= (window.innerHeight || document.documentElement.clientHeight));
  };

  /*
   * Changing src attr logic
   */
  var echoSrc = function (img, callback) {
    img.src = img.getAttribute('data-echo');
    if (callback) {
      callback();
    }
  };

  /*
   * Remove loaded item from array
   */
  var removeEcho = function (element, index) {
    if (echoStore.indexOf(element) !== -1) {
      echoStore.splice(index, 1);
    }
  };

  /*
   * Echo the images and callbacks
   */
  var echoImages = function () {
    for (var i = 0; i < echoStore.length; i++) {
      var self = echoStore[i];
      if (scrolledIntoView(self)) {
        echoSrc(self, removeEcho(self, i));
      }
    }
  };

  /*
   * Prototypal setup
   */
  Echo.prototype = {
    init : function () {
      echoStore.push(this.elem);
    },
    render : function () {
      if (document.addEventListener) {
        document.addEventListener('DOMContentLoaded', echoImages, false);
      } else {
        window.onload = echoImages;
      }
    },
    listen : function () {
      window.onscroll = echoImages;
    }
  };

  /*
   * Initiate the plugin
   */
  var lazyImgs = document.querySelectorAll('img[data-echo]');
  for (var i = 0; i < lazyImgs.length; i++) {
    new Echo(lazyImgs[i]).init();
  }

})(window, document);

The script takes an Object-Orientated approach, instantiating the Echo object (which is our Function constructor) on each element instance of NodeList inside our for loop. You can see this instantiation at the end of the script, using the new operator.

The first main chunk of code we see if an anonymous function expression which acts as our Constructor. Following convention, Constructor function names should have the first letter capitalised:

var Echo = function (elem) {
  this.elem = elem;
  this.render();
  this.listen();
};

I pass in the elem argument, which will be the current element inside the for loop in which the plugin is called upon, and call render.(); and listen(); internally, this will run the prototype functions that the Object inherits.

Next is an empty array:

var echoStore = [];

This empty array will act as our data store for pushing our images that need lazy-loading into. It's a good practice to use arrays for this type of thing so we can remove images that are already loaded from the same array, this will prevent our loops iterating over the same array, it may as well perform faster and loop over fewer items.

Next, here's a neat little function to detect whether the element is in view:

var scrolledIntoView = function (element) {
  var coords = element.getBoundingClientRect();
  return ((coords.top >= 0 && coords.left >= 0 && coords.top) <= (window.innerHeight || document.documentElement.clientHeight));
};

This uses a great addition to JavaScript, the .getBoundingClientRect() method which returns a text rectangle object which encloses a group of text rectangles, which are the border-boxes associated with that element, i.e. CSS box. The returned data describes the top, right, bottom and left in pixels. We can then make a smart comparison against the window.innerHeight or the document.documentElement.clientHeight, which gives you the visible area inside your browser on a cross-browser basis.

Next up is a very simple function that switches the current image's src attribute to the associated data-echo attribute once it's needed:

var echoSrc = function (img, callback) {
  img.src = img.getAttribute('data-echo');
  if (callback) {
    callback();
  }
};

If a callback is present, it will run (I do pass in a callback here, but to prevent errors it's good to simply if statement this stuff).

The next function I've setup to check if the current element exists in the array, and if it does, it removes it using the .splice() method on the current index to remove 'itself':

var removeEcho = function (element, index) {
  if (echoStore.indexOf(element) !== -1) {
    echoStore.splice(index, 1);
  }
};

The fundamental tie in for the script is listening for constant updates in the view based on our data store array. This function loops through our data store, and checks if the current element in the array is in view after initiating the scrolledIntoView function. If that proves to be true, then we call the echoSrc function, pass in the current element and also the current element's index value, being i. This index value gets passed into the removeEcho function which in turn removes a copy of itself from the array. This means our array has become shorter and our JavaScript doesn't have to work as hard or as long when looping through our leftover elements.

var echoImages = function () {
  for (var i = 0; i < echoStore.length; i++) {
    var self = echoStore[i];
    if (scrolledIntoView(self)) {
      echoSrc(self, removeEcho(self, i));
    }
  }
};

The OO piece of the script looks inside the prototype extension, which has a few functions inside. The first is the init() function, that simply pushes the current element into our data store array. The render() function checks to see if an addEventListener event exists, which will then invoke the echoImages function once the DOMContentLoaded event is fired. If it doesn't exist, likely inside IE7/8, it'll just run onload. The listen() function will just run the function again each time the window is scrolled, to poll and see if any elements come into view to work it's magic some more.

Echo.prototype = {
  init : function () {
    echoStore.push(this.elem);
  },
  render : function () {
    if (document.addEventListener) {
      document.addEventListener('DOMContentLoaded', echoImages, false);
    } else {
      window.onload = echoImages;
    }
  },
  listen : function () {
    window.onscroll = echoImages;
  }
};

The final piece of the script is the beautiful API where you invoke a new Object on each item in a NodeList:

var lazyImgs = document.querySelectorAll('img[data-echo]');
for (var i = 0; i < lazyImgs.length; i++) {
  new Echo(lazyImgs[i]).init();
}

I chose to run a regular for loop on this, but if you're routing for more modern JavaScript APIs you can of course do this which is much cleaner but unsupported in older IE (yes I can polyfill but the script is too small to warrant it):

[].forEach.call(document.querySelectorAll('img[data-echo]'), function (img) {
  new Echo(img).init();
}