A Simple Lazy Loader

Chapter 3 21 mins

Learning outcomes:

  1. Implementing lazy loading in JavaScript

Introduction

In the previous Lazy Loading Basics chapter we layed out the HTML and CSS for our lazy loader and it is now that we will be looking over how its logic works.

We will be considering the most basic lazy loading algorithm discussed previously and progressively build on it with every subsequent chapter.

We always need to start simple before going complex. This is the principle to be followed in many practical applications even programming.

Anyways let's begin exploring a simple lazy loader.

Revisiting the HTML

Let's revisit the HTML we established in the previous chapter to get a better idea of how are we laying out our JavaScript code:

<div class="lazy"><img class="lazy_img" data-src="someImage.png" /></div>

We have a .lazy element that acts as a container for the lazy image, which itself appears as .lazy_img. The image has a data-src attribute on it to temporarily store its URL.

With this HTML in place, we will now construct a demo HTML page for a fully functional lazy loader.

Following we add a <div> element before the .lazy container and give it a significantly large height value to emulate the presence of some content before the lazy image. We want to make the loading feature look more obvious.

<div style="height: 800px;background-color: #eee">A large div</div>
<div class="lazy"><img class="lazy_img" data-src="image.png" /></div>

With this <div> (due to its significant height) the lazy image will go below-the-fold, and make the lazy loading idea more obvious.

If you don't know what is above-the-fold or below-the-fold consider reading this article on the difference between above- and below-the-fold.

So now with a good emulation set up, let's finally move over to the real part i.e JavaScript coding.

Offset calculations

As highlighted earlier, the whole esscence of lazy loading lies in element offsets and scroll events. Likewise let's start by exploring offsets first.

The offsets we are concerned with in lazy loading are the positions of elements relative to the bottom or right edge of the viewport.

For a detailed guide to offsets read our JavaScript Element Offsets chapter which teaches the concept from the crust to the core.

If we have these in hand, then we know how far an element is from the bottom or right edge of the viewport and, therefore, from being scrolled into view.

For example if an element is 10px far from the viewport's bottom edge, then it would take us to scroll for 10px vertically in order to bring the element almost into view.

At 10px scroll, the element will be just touching the bottom edge of the viewport, but not completely into view. Nonetheless, we'll load the image right at this point so that if the user scrolls further than this, we're ready with the image.

It's not a necessity to load the image once it merely touches the bottom edge of the viewport — this is just one of many choices to do so. You can choose to load the image when it appears partially, or completely, in the viewport.

So first thing's first, let's write the code to record a lazy image's offset.

We'll assume we have only a single image to lazy load and will consequently write code for only one image. This will keep things easy and straightforward for now.

Let's begin the thinking process...

Which of the following can we use to get the offset of our lazy image?

  • getBoundingClientRect() method
  • offsetParent property
  • style.top property

When calculating the offset of an element, there is a chance that the page is scrolled automatically before the calculation is made.

In this case the offset calculated using getBoundingClientRect() won't be correct.

What can we do to rectify this issue?

  • Add the amount of scroll done in getBoundingClientRect().top
  • Subtract the amount of scroll from getBoundingClientRect().top
Read JavaScript Bounding Boxes to understand how the method getBoundingClientRect() works.

Here's the plan:

We'll call getBoundingClientRect() on .lazy_img, retrieve its top property in order to calculate its offset from the top of the viewport, and then add window.innerHeight in this value to get the offset from the bottom of the viewport (the one we desire).

First we ought to select the lazy image and then perform the desired calculations on it. This is accomplished below:

var lazyImage = document.getElementsByClassName("lazy_img")[0];
var offset = lazyImage.getBoundingClientRect().top + window.pageYOffset - window.innerHeight;
Instead of writing window.pageYOffset and window.innerHeight, you can also go with just pageYOffset and innerHeight.

The variable lazyImage holds the lazy image while offset holds its distance from the bottom edge of the viewport.

The offset is calculated as follows:

  1. lazyImage.getBoundingClientRect().top returns the distance of the image from the top edge of the viewport.
  2. Into this we add window.pageYOffset to get the distance of the image from the top of the page.
  3. Finally we subtract window.innerHeight to get the distance of the element from the bottom edge of the viewport.

The final value obtained specifies the distance one would need to scroll vertically (from the top of the document) in order to get the image to touch the bottom edge of the viewport.

Note that it's possible that offset turns out to be negative.

If offset turns out to be negative what does that signify?

  • The image is above-the-fold
  • The image is below-the-fold
  • The offset calculated is wrong
If you don't know what is above-the-fold or below-the-fold consider reading: difference between above- and below-the-fold.

If offset is negative, for example -5, or equal to 0, it simply means that the image is right away visible in the viewport as the document is loaded and should therefore be loaded immediately — NO need to lazily load it!

Following we implement this check:

var lazyImage = document.getElementsByClassName("lazy_img")[0];
var offset = lazyImage.getBoundingClientRect().top + window.pageYOffset - window.innerHeight;

if (offset < 0 || offset === 0) {
    // load the image
}

The comment will be replaced in the next section where we'll see exactly how to load a lazy image.

Handling the scroll

With the offset calculated, now we are just left with adding an onscroll handler that compares the distance scrolled with it, and once the scroll distance exceeds the offset, it loads the image.

To load the image we'll use a function loadImage() that we'll define just in a moment. For now, it's more important to appreciate how to lay out the onscroll handler.

Implementing this we get the following:

window.onscroll = function() {
    if (window.pageYOffset > offset) {
        // load the image
    }
}

The conditional in line 2 checks whether the distance scrolled vertically has exceeded the offset of the lazy image, and if it has, executes a block of code to load the image.

We'll see what this block of code is, very shortly.

If you're wondering why didn't we give a scroll listener using the addEventListener() method; well it was just a simple choice for now to go with onscroll.

However, indeed, in the coming chapters we'll use the addEventListener() method to attach multiple scroll listeners on window, for each lazy element where onscroll can't suffice. But this would eventually turn out inefficient as well and likewise we'll need to mitigate it later on.

The addEventListener() method is also generally the preferred way to handle events when creating JavaScript libraries.

Anyways, these are concerns of the future — for now, let's focus on the present!

Loading algorithm

In the code above, we stated that a function loadImage() will take over when an image is to be loaded into view.

Now we shall get to know how that function works!

But before we dive into exploring the function, it's worth a while to understand why do we even need a function to load images. Why can't we simply write the necessary statements directly inside the conditional blocks above.

Here's the deal:

Whenever you find code repetition in a program, consider encapsulating the repetitive statements inside a function and then calling that function, in place of those repetitive statements.

This makes it way easier to write, understand and debug code. Believe it!

In our case, we know that the loading logic is required in two places: one is the if block that checks for a non-positive offset value, while the other is the if block that checks for pageYOffset exceeding offset.

Both these, essentially, require the same loading logic, and so it'll be inefficient to merely copy paste it into both these locations.

Thereby, what we do is create a function loadImage() to hold the loading logic.

Coming back to the topic, let's finally see how to define loadImage().

To load an image we just need to give it a src attribute.

But where do you think we will get the value to be put into src?

Yes you're right! From the data-src attribute, we gave in the previous chapter.

Here's the loading algorithm:

  1. Fetch the data-src attribute of the image.
  2. Assign this to its src attribute

And we're done!

For the first step of fetching data-src of the image, you can either use getAttribute() or dataset. We'll go with the former, due to its wide browser support.

For the second step of assigning a value to the src attribute of the image, you can either use setAttribute() or the src property. Once again, we'll go with the former, due to its wide browser support.

Moving on, since loadImage() is a global function and not a method of the lazy images, we'll need to pass it the respective <img> element as an argument.

Gathering all these bits and pieces, we arrive at the following definition for loadImage():

function loadImage(img) {
    img.src = img.getAttribute("data-src");
}

And with this, here's the complete code utilising it:

var lazyImage = document.getElementsByClassName("lazy_img")[0];
var offset = lazyImage.getBoundingClientRect().top + window.pageYOffset - window.innerHeight;

if (offset < 0 || offset === 0) {
    loadImage(lazyImage);
}

window.onscroll = function() {
    if (window.pageYOffset > offset) {
        loadImage(lazyImage);
    }
}

This marks the completion of our basic lazy loading algorithm. Let's recap the steps very quickly.

  1. Calculate top offset of the image.
  2. Subtract viewport's height from it and save value in offset.
  3. Assign scroll event to window.
  4. Add a conditional to check for when scrolled more than offset.
  5. When condition met assign data-src value to src.

And here we have a full fledge, perfectly working lazy loader which will as said earlier form the foundational basis for the coming advanced chapters.

Simple Lazy Loader

Preventing loading delays

So far we have covered all that we needed to power a simple lazy loader operating on a single image. However, there is one thing additional that can be done to make the lazy loader even more effective.

Let's see what that one thing is.

With the current lazy loading code in place, if an image is detected to be above the fold, it's loaded immediately. This can ruin the essence of lazy loading — that is, to reduce the loading time of the site.

If an image is loaded when the load event has not yet fired on the document, then it will further delay the event as it will now become a resource of the document which has to load first before the document itself gets loaded (i.e the load event fires on window).

The issue isn't just limited to above-the-fold images. If an image is below the fold, and the document is loaded in such a way that it automatically gets scrolled to the image (such as by using an id in the URL, or by refreshing the document when it had been scrolled to a certain position), then also the same thing might happen.

If the automatic scroll (performed by the browser itself) happens before the document's load event, the lazy loading code would detect that a lazy image has entered into view and would consequently load it, thus causing a delay in the firing of the document's load event.

This can be mitigated by carefully planning out the way we load an image.

The best way is to make sure that every single image gets loaded only after the document's load event occurs.

This can be accomplished by encapsulating the entire lazy loading logic inside the onload handler for window.

In the code below we've shifted our entire script into window.onload:

window.onload = function() {

    var lazyImage = document.getElementsByClassName("lazy_img")[0];
    var offset = lazyImage.getBoundingClientRect().top + window.pageYOffset - window.innerHeight;

    if (offset < 0 || offset === 0) {
        loadImage(lazyImage);
    }

    window.onscroll = function() {
        if (window.pageYOffset > offset) {
            loadImage(lazyImage);
        }
    }

}
The definition for loadImage() can go outside window.onload.

Simple!

But as simple as it sounds, it actually isn't. There is one case that doesn't get solved.

Before we see that, let's first see the cases that are solved.

Above-the-fold image

Suppose the lazy image is above the fold. We wait till the onload event fires after which our loading script code into action. In the code, the conditional for a negative offset is evaluated. It returns true and likewise the corresponding block of code is executed.

Remember that this conditional block checks for an above-the-fold image and loads it right away, without the need to scroll any further.

The load event didn't get delayed, yet our image got loaded — case solved.

Now suppose that the lazy image is below the fold, and that the document has been automatically scrolled to it. There are two ways in which this could happen based on the occurence of the load event:

  1. The load event fires before scroll.
  2. The load event fires after scroll.

Let's consider each case.

Below-the-fold image, load occurs before scroll

We wait till the loadevent fires, after which our loading script code into action. After load, the scroll event takes place and causes the onscroll handler to take over. The handler recognises that the distance scrolled vertically has exceeded the offset of the lazy image, and likewise loads it.

Case solved.

Below-the-fold image, load occurs after scroll

We wait till the load event fires after which our loading script comes into action. Before this load event, the scroll event takes place. Now remember that the load event has not yet fired and so the lazy loading script hasn't been executed yet.

Once load fires, the code gets executed. However, the lazy image isn't loaded even though it's in view. This is because the onscroll handler inside onload hasn't fired — recall that the scroll event fired before load.

This case is unresolved.

How to resolve the second case detailed above?

We just need to monitor for any scroll event occuring before the load event. If a scroll event occurs, we change a global variable to true. This variable is checked by our lazy loading code as soon as it gets executed.

If it's true, we know that a scroll event has occured previously, and likewise call the onscroll handler function manually.

Case solved!

Below shown in the complete code:

window.onload = function() {
    /* ... */

    window.onscroll = function() {
        if (window.pageYOffset > offset) {
            loadImage(lazyImage);
        }
    }

    if (scrollingPerformed) {
        if (window.pageYOffset > offset) {
            loadImage(lazyImage);
        }
    }

}

var scrollingPerformed = false;
window.onscroll = function() {
    // indicate that scrolling has been performed
    scrollingPerformed = true;
}

Note that this code has repetition of statements in it — inside the onscroll handler (lines 5 - 7) and inside the conditional for scrollingPerformed (lines 11 - 13).

This repetition can be avoided by using a function.

First let's see what the respective bunch of statements is doing and then come up with a function name based on it. Altogether the block of statements is checking if window.pageYOffset has exceeded offset, and if it has, loading the lazy image.

Can you give a good name for the function that will be holding this bunch of statements?

We've come up with monitorImage(), as the function merely monitors the lazy image.

Below shown is the complete code, rectified for code repetition:

window.onload = function() {
    /* ... */

    function monitorImage() {
        if (window.pageYOffset > offset) {
            loadImage(lazyImage);
        }
    }

    window.onscroll = monitorImage;

    if (scrollingPerformed) {
        monitorImage()
    }

}

var scrollingPerformed = false;
window.onscroll = function() {
    // indicate that scrolling has been performed
    scrollingPerformed = true;
}

What you should take from this is that whenever coding a component, library, or any feature in general for the web, it's paramount to test it thoroughly.

In our case, had we not tested our lazy loader, it would've surely worked without any doubt, but not in the most effective way.

Testing requires one to evaluate different cases, see if each one works as expected, and solve any one if it doesn't. Solving a case requires expreience and strong knowledge in programming.

Simple Lazy Loader