Lazy Loading Images with the Igaro App JavaScript Framework

Share this article

A while back I wrote about the Igaro App JS Framework (disclaimer: I’m the framework author).

“Sigh! Not another framework” I hear you say (and probably rightly so). Well, let me tell you what sets Igaro app apart.

Igaro App is NOT yet another framework that plugs into your HTML. It’s a completely different approach that offers potentially the highest performance of any web-app framework out there. It’s based on the latest standardized technologies such as promises (and zero callbacks), as well as an event driven architecture. There’s superb error management and recovery, a lazy loading architecture using CommonJS style modules, many widgets to get you started, and zero dependencies (no jQuery).

In this article I’ll be demonstrating how to build an unveil widget (lazy loading images when they come into view) for Igaro App and will highlight many of the concepts that make the framework shine along the way. If you wish to jump straight to the end result, you can download the complete code for this article.

Setting Up the Environment

The first thing to do is to grab a copy of the framework from its GitHub repo.

mkdir igaro
git clone https://github.com/igaro/app.git igaro/git
cd igaro/git

Then install a couple of dependencies:

npm install -g grunt-cli
gem install compass
npm install

Grunt’s command line interface (grunt-cli) is an npm package, which means you’ll need Node.js and npm installed on your machine. Compass is a Ruby gem, which means you’ll need to install Ruby as well. The installation procedure will vary depending on the operating system. The best thing to do is follow the instructions on the respective projects’ home page (Node, Ruby).

With that done, you can kick things off with a simple:

grunt

Once cloned and running, the user has a development environment ready to go. Igaro compiles into two modes — debug and deploy and a web server for each can be found on ports 3006 and 3007 respectively. These will reload automatically as you work.

Screen shot of a standard Igaro App install

Outlining the Widget Specifications

In the course of building the widget, I’ll be covering Igaro’s bless, a means of pre-configuring objects, and will explain how it allows objects to tidy up after themselves. For an SPA this is important to thwart memory leaks and security issues, i.e if an authenticated page (Igaro App refers to these as routes) contains several widgets of the type we’re about to create, and credentials are invalidated (i.e the user has logged out) then it’s not just the DOM elements which must be removed, but events and dependencies must also be released.

Most frameworks expect you to reload the app “refresh the page” to clear historical objects (even if the DOM side of things are removed or hidden) or to handle the process of clearing variables manually. One feature of Igaro’s “bless” is two way communication between objects, so in this case when the route is destroyed, the widget goes with it. Similarly, if we destroy the widget, the route is notified and it’s removed from a siblings array pool.

As a disclaimer, I prefer code which flows and reads like a book in a fashion that is self documenting to anyone that has experience in the language type. For that reason you will find all the following code is undocumented, condensed, and yet surprisingly readable, in no small thanks to the use of ES6 Promises. You should have a good level of JavaScript fundamentals or be prepared to learn.

Without further ado, here’s the specification for our widget:

  1. The container should be an empty <div>.
  2. On window scroll or resize, detect whether vertical position is within viewport and if so add a loading CSS class.
  3. Fetch any resource and if an image switch <div> to <img> and write data out.
  4. Support a callback function after the Ajax call*. This could inject other DOM elements or handle custom data.
  5. On error, add error CSS class, remove loading class.

*The Ajax call may require headers for authentication or CORS support. A mechanism to allow for customizing the request must be implemented.

Now we know how the widget should behave, let’s start to code.

Creating the Necessary Files

Let’s examine the four main files necessary for our widget.

instance.unveil.js

Create a file named instance.unveil.js in compile/cdn/js/ and enter the code below:

module.requires = [
  { name:'instance.unveil.css' }
];

module.exports = function(app) {
  "use strict";
  var InstanceUnveil = function(o) {}
  return InstanceUnveil;
};

When the widget is instantiated an object literal o is passed. This is used to bless the object (more on this later).

instance.unveil.scss

Next, create a file named instance.unveil.scss in sass/scss and enter the code below.

.instance-unveil {
  display:inline-block
}

.instance-unveil-loading {
  background: inline-image("instance.unveil/loading.gif") no-repeat 50% 50%;
  background-size: 3em;
}

.instance-unveil-error {
  background: inline-image("instance.unveil/error.svg") no-repeat 50% 50%;
  background-size: 3em;
}

Find a suitable loading gif and a suitable error image on the web. Put these into a folder named sass/images/instance.unveil and ensure the name and extension match those in the file you just created.

route.main.unveiltest.scss

A test page (route) containing multiple instantiations of our widget will be accessible through http://localhost:3006/unveiltest.

Create a file named route.main.unveiltest.scss in sass/scss and enter the code below.

@import "../sass-global/mixins.scss";

body >.core-router >.main >.unveiltest >.wrapper {
  @include layoutStandard;
}

route.main.unveiltest.js

Create a file named route.main.unveiltest.js in compile/cdn/js and enter the code below.

//# sourceURL=route.main.unveiltest.js

module.requires = [
  { name: 'route.main.unveiltest.css' },
];

module.exports = function(app) {
  "use strict";
  return function(route) {

    var wrapper = route.wrapper,
    objectMgr = route.managers.object;

    return route.addSequence({
      container:wrapper,
      promises:Array.apply(0,new Array(50)).map(function(a,i) {
        return objectMgr.create(
          'unveil',
          {
            xhrConf : {
              res:'http://www.igaro.com/misc/sitepoint-unveildemo/'+i+'.jpeg'
            },
            loadImg : true,
            width:'420px',
            height:'240px'
          }
        );
      })
    });
  };
};

In Igaro App, when a page is requested, the router (core.router) asks a provider for a source, instantiates a new route and passes it to the source for customization. In the route file you just created, fifty unveil widgets are created and passed to a sequencer. The sequencer ensures that as the returned promises resolve, the images are placed on the page in the original order.

The create method is provided by a manager. It lazy loads the module and creates an instantiation (pre-load a module by adding it to the requires list at the top of the file). At this point, the widget is also dependency linked to the route so that when the route is destroyed, clean up operations are run.

Adding the Widget’s Functionality

Enhance your instance.unveil.js file with the following code:

module.requires = [
  { name:'instance.unveil.css' }
];

module.exports = function(app) {
  "use strict";

  var bless = app['core.object'].bless;

  var InstanceUnveil = function(o) {
    var self = this;
    this.name='instance.unveil';
    this.asRoot=true;
    this.container=function(domMgr) {
      return domMgr.mk('div',o,null,function() {
        if (o.className)
          this.className = o.className;
        this.style.width = o.width;
        this.style.height = o.height;
      });
    };
    bless.call(this,o);
    this.onUnveil = o.onUnveil;
    this.xhrConf = o.xhrConf;
    this.loadImg = o.loadImg;
  };

  return InstanceUnveil;
};

Attributes provided by the argument o can be used directly, such as o.container and o.className (which indicate where the widget should be inserted and offer a custom class name). Some are written directly, such as a name for the object, which is used by an event manager provided by Igaro’s bless feature. Bless can provide many things, for example if the widget required persistent data storage, we can ask it to attach a store manager (view the code behind http://localhost:3006/showcase/todomvc for an example).

Add Window Event Handlers

Update your instance.unveil.js file to include the window listener hooks, clean-up function and basic prototype methods as shown below. You can replace the previous contents of the file with the code below if you’d prefer to do so.

module.requires = [
  { name:'instance.unveil.css' }
];

module.exports = function(app) {
  "use strict";

  var bless = app['core.object'].bless;

  var removeWindowListeners = function() {
    var wh = this.__windowHook;
    if (wh) {
      window.removeEventListener('scroll',wh);
      window.removeEventListener('resize',wh);
    }
    this.__windowHook = null;
  };

  var InstanceUnveil = function(o) {
    var self = this;
    this.name='instance.unveil';
    this.asRoot=true;
    this.container=function(domMgr) {
      return domMgr.mk('div',o,null,function() {
        if (o.className)
          this.className = o.className;
        this.style.width = o.width;
        this.style.height = o.height;
      });
    };
    bless.call(this,o);
    this.onUnveil = o.onUnveil;
    this.xhrConf = o.xhrConf;
    this.loadImg = o.loadImg;
    this.__windowHook = function() {
      return self.check(o);
    };
    window.addEventListener('scroll', this.__windowHook);
    window.addEventListener('resize', this.__windowHook);
    this.managers.event.on('destroy', removeWindowListeners.bind(this));
  };

  InstanceUnveil.prototype.init = function(o) {
    return this.check(o);
  };

  InstanceUnveil.prototype.check = function(o) {
    return Promise.resolve();
  };

  return InstanceUnveil;
};

The instance now attaches listeners to the window scroll and resize events which will invoke the check function (which will do the calculation to see if our widget is within the viewport space). Critically, it also attaches another listener to the event manager on the instance, so as to remove the listeners if the instance is destroyed. There’s also a new prototyped function called init. JavaScript instantiation via the new keyword is synchronous, but asynchronous code can be placed into init instead and it’ll be called it for us.

In Igaro App any blessed object can be destroyed by calling destroy on it.

At this point, the code still won’t do anything. If you browse to /unveiltest, you’ll be provided with a blank page (but inspect the content and you will see fifty blank <div> elements). The heavy lifting is yet to be added to the check function.

The check Function

This function should do the following:

  • Detect if the instance’s container (a <div> element) is within the viewport
  • Add a loading CSS class
  • Create a XHR instance
  • Fetch the resource
  • If loading an image, swap the <div> to an <img>
  • Optionally call a callback
  • Remove the loading CSS class
  • Clean up the event handlers

There’s quite a lot of code to the check function, but take your time and follow it through — it reads well. Add it to your file, and don’t forget the reference to the dom module near the top.

//# sourceURL=instance.unveil.js

module.requires = [
  { name:'instance.unveil.css' }
];

module.exports = function(app) {
  "use strict";

  var bless = app['core.object'].bless,
  dom = app['core.dom'];

  var removeWindowListeners = function() {
    var wh = this.__windowHook;
    if (wh) {
      window.removeEventListener('scroll',wh);
      window.removeEventListener('resize',wh);
    }
    this.__windowHook = null;
  };

  var InstanceUnveil = function(o) {
    var self = this;
    this.name='instance.unveil';
    this.asRoot=true;
    this.container=function(domMgr) {
      return domMgr.mk('div',o,null,function() {
        if (o.className)
          this.className = o.className;
        this.style.width = o.width;
        this.style.height = o.height;
      });
    };
    bless.call(this,o);
    this.onUnveil = o.onUnveil;
    this.xhrConf = o.xhrConf;
    this.loadImg = o.loadImg;
    this.__windowHook = function() {
      return self.check(o);
    };
    window.addEventListener('scroll', this.__windowHook);
    window.addEventListener('resize', this.__windowHook);
    this.managers.event.on('destroy', removeWindowListeners.bind(this));
  };

  InstanceUnveil.prototype.init = function(o) {
    return this.check(o);
  };

  InstanceUnveil.prototype.check = function() {
    var container = this.container;
    // if not visible to the user, return
    if (! this.__windowHook || dom.isHidden(container) || dom.offset(container).y > (document.body.scrollTop || document.documentElement.scrollTop) + document.documentElement.clientHeight)
      return Promise.resolve();
    var self = this,
    managers = this.managers,
    xhrConf = this.xhrConf;
    removeWindowListeners.call(this);
    container.classList.add('instance-unveil-loading');
    return Promise.resolve().then(function() {
      if (xhrConf) {
        return managers.object.create('xhr', xhrConf).then(function(xhr) {
          return xhr.get(self.loadImg? { responseType: 'blob' } : {}).then(function(data) {
            if (self.loadImg) {
              self.container = managers.dom.mk('img',{ insertBefore:container }, null, function() {
                var img = this,
                windowURL = window.URL;
                // gc
                this.addEventListener('load',function() {
                  windowURL.revokeObjectURL(img.src);
                });
                this.src = windowURL.createObjectURL(data);
                this.className = container.className;
                this.style.height = container.style.height;
                this.style.width = container.style.width;
              });
              dom.purge(container);
              container = self.container;
            }
            return data;
          }).then(function(data) {
            if (self.onUnveil)
              return self.onUnveil(self,data);
          }).then(function() {
            return xhr.destroy();
          });
        });
}
if (self.onUnveil)
  return self.onUnveil(self);
}).catch(function(e) {
  container.classList.add('instance-unveil-error');
  container.classList.remove('instance-unveil-loading');
  throw e;
}).then(function() {
  container.classList.remove('instance-unveil-loading');
});
};

return InstanceUnveil;
};

Why did we need to add the core.dom module when our blessed object has a DOM manager you may ask?

Bless only provides functions that require customizing for the object being blessed, hence the DOM manager doesn’t provide the purge method required to obliterate the original container (and all it’s dependencies). For this reason the following two methods for creating a DOM element are not the same:

app['core.dom'].mk(...)

[blessed object].managers.dom.mk(...)

The second example will destroy the DOM element if the blessed object is destroyed, as well as any events which have the DOM element registered as a dependency. It automates all the clean-up and ensures there’s no memory leaks.

Refresh and on the page should be many colorful images.

Failure!

As you hopefully found out, we don’t have many images at all. Can you work out what went wrong?

Two things;

  1. The instance isn’t appending its DOM element, that is done by the addSequence function but it happens after our immediate call to check.

  2. The route isn’t visible until its promise is resolved, which potentially allows the router to abort loading a broken page. Even if we fixed (1) the images wouldn’t be in the viewport when check is called.

The problem being experienced is unlikely to crop up in many use cases but it is an excellent example of what happens when you use a framework to create an SPA, and ultimately can that framework easily resolve the unexpected, or will it just get in the way?

At this point, detaching the process via setTimeout (HACK!) may have crossed your mind. We won’t do that.

Solution

core.router handles the loading of routes, and being blessed it fires an event to-in-progress when a route has loaded and is visible. We can wire our instance to this call.

Based on code used earlier, something like the following should be suitable.

app['core.router'].managers.event.on('to-in-progress',function(r) {
  if (r === route)
    unveil.check(); // no return
}, { deps:[theInstance] });

Note how the instance is passed as a dependency of the event and the promise from check is not returned. Doing so would cause the images to load one after another (events are synchronous) and also if an error occured on fetching the image it would abort the loading of the page. Instead the instance is to handle the error independently (via the CSS error class).

The final code for the route.main.unveiltest.js is thus as follows:

//# sourceURL=route.main.unveiltest.js

module.requires = [
  { name: 'route.main.unveiltest.css' },
];

module.exports = function(app) {
  "use strict";

  var coreRouterMgrsEvent = app['core.router'].managers.event;

  return function(route) {
    var wrapper = route.wrapper,
    objectMgr = route.managers.object;

    return route.addSequence({
      container:wrapper,
      promises:Array.apply(0,new Array(50)).map(function(a,i) {
        return objectMgr.create(
          'unveil',
          {
            xhrConf : {
              res:'http://www.igaro.com/misc/sitepoint-unveildemo/'+i+'.jpeg'
            },
            loadImg : true,
            width:'420px',
            height:'240px'
          }
          ).then(function(unveil) {
            coreRouterMgrsEvent.on('to-in-progress',function(r) {
              if (r === route)
                unveil.check(); // no return
            }, { deps:[unveil] });
            return unveil;
          });
        })
    });
  };
};

Refresh and you should now have many images unveiling themselves as you scroll down the page.

Gif demonstrating lazy loading

Error Handling

Changing the amount of images in the route file to a higher number will invoke an Ajax failure and the display of the error CSS class.

Improvement Thoughts

Earlier I noted that the window.addEventListener on the instance should ideally be removed once the route goes out of scope, which would be more efficient than the instance checking the visibility of its container.

As it transpires, this is possible by listening to the enter and leave events on the route. We could monitor these and call register/deregister methods on the instance.

Final Considerations

One caveat is our friend Internet Explorer. Version 9 does not support XHRv2 and window.URL.createObjectURL, neither of which can be polyfilled.

To indicate to the user that their browser doesn’t support a required feature we can add the following code to the top of instance.unveil.js.

if (! window.URL))
  throw new Error({ incompatible:true, noobject:'window.URL' });

For images at least, I don’t view this as acceptable. Before this code is ready for production it would need to fallback to immediately writing out the image should window.URL be unavailable.

Conclusion

While writing this article I investigated using the returning MIME type to automatically write the replacement <img> and using base-64 to support IE9. Regrettably XHRv1 requires a MIME override which then overrides the the content-type header. Resolving it requires two XHR calls to the same URL.

I plan to integrate this instance module into an upcoming release of Igaro App, but you can beat me to it by sending a pull request (if you do, don’t forget non window.URL support and documentation via route.main.modules.instance.unveil.js).

Otherwise, I hope to have provided you with a glimpse of what Igaro App can do. I would be glad to answer any questions you might have in the comments below.

Andrew CharnleyAndrew Charnley
View Author

Andrew Charnley is a JavaScript Developer & System Architect contracting out around the world. A keen traveller, he typically cycles from role to role.

es6Gruntimagesjameshjavascript frameworklazy loading
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week