Intersection Observer

Chapter 11 6 mins

Learning outcomes:

  1. Using the IntersectionObserver API

Introduction

Back then writing code for tracking the positions of HTML elements meant using scroll listeners, resize listeners coupled with calling getBoundingClientRect() on each element initially, then utilising the value obtained inside a conditional.

In short, it was a full routine to monitor the positions of HTML elements on a web page. This work was fine until things were in low numbers, but once they weren't it prove to largely compromise on performance and interaction of a web page.

Majority of devices like phones and laptops weren't of that capable hardware to cope with a number of these routines, plus more work put by developers, without introducing even the least of jank into the user's experience. Remember that we aren't talking here from the perspective of lazy loading. What if we have an algorithm that needs to track 10 ad banners placed on a page together with which ones of them show for how much amount of time and by how much area?

You get the idea right? The algorithm will at some point become janky causing extra work on the main browser thread and stealing room for other activites such as the smooth CSS transitions and animations.

All this mess involved in the procedures for tracking element positions ultimately led to the evolution of a dedicated API for the purpose - IntersectionObserver.

The IntersectionObserver API comes with oven-fresh methods to track, or in the language of the API, 'observe' the positions of elements relative a 'root' element (usually the viewport). The calculations performed by these observers in the background are asynchronous and therefore don't populate the main thread of a browser, thereby letting it to do other tasks pretty smoothly.

So a lot of detailing into IntersectionObservers, now let's finally move into using them together with lazy loading.

Observe any changes

The code for a lazy loader operating on intersection observers is much the same as that for the ones operating on scroll listeners. We'll just need to do a bit of tweaks around in the previous code to be able to get it to work with intersection observers also, if supported.

Creating an instance

So starting off with the first thing since many devices still don't support IntersectionObserver() out of the box we'll begin by checking for its availability in the browser and only proceed if it's supported; otherwise simply fallback to our scroll-based procedure from the previous chapter.

if (window.IntersectionObserver) {
    // IntersectionObserver is supported
    // code with observers
} else {
    // use the old scroll-based algorithm
}

With the check out of the way let's now move further and see how to actually use the API to our use. We'll instantiate the IntersectionObserver() constructor and then observe all lazy images using its observe() method.

So let's create an instance of IntersectionObserver() based on our requirements.

var options = {
    root: null,
    threshold: 0
};
var io = new IntersectionObserver(observeImage, options);

The first argument to IntersectionObserver() is the function to use when an image crosses the 'threshold' defined in the second argument.

The second argument here is an object containing a property root which defines the element relative to which all images shall be compared. In our case we've left is as null to make the root default to the viewport, since we need to check when an image enters the viewport.

The second property threshold defines the ratio of a lazy image that shall be intersecting with the viewport before it is sent into the task of being loaded. 0 means that as soon as it just touches the viewport, it shall be loaded.

Let's now see what the function observeImage() looks like:

function observeImage(entries, observer) {
    entries.forEach(element => {
        if (element.isIntersecting) {
            element.target.src = element.target.dataset.src;
            io.unobserve(element.target)
        }
    });    
}

Looks complicated right? Here's what's happening.

  1. The function is called by the intersection observer io created above, with two arguments entries - all the elements that matched the threshold for the observer and observer - the actual intersection observer object.
  2. The entries argument is an array containing all elements that met the threshold, hence we iterate over it using the array method forEach() and load each image into view.
  3. After loading the image we unobserve it from being observed any further.

Using the observe() method

With an instance created and saved in io as well as the function observerImage() set up, now we only have to 'observe' all .lazy elements using the method observe().

Following is the code to observe all lazy images in the for loop we saw in the previous Efficiency chapter, as well in the Multiple Images chapter.

for (var i = 0, len = lazyConts.length; i < len; i++) {

    io.observe(lazyImages[i]); // observe the image
    (function(i) {

        // .loader code from previous chapter
        // reloader code from previous chapter

        lazyImages[i].onload = function () {
            loader.style.display = "none";
            lazyImages[i].classList.remove("unloaded"); // remove class .unloaded
            lazyImages[i].onerror = null;
        }
    })(i);

}

Adding a timeout

The code we saw above will truly work without any problem whatsoever, however it won't wait for a certain amount of time before loading a given image. This can be done by adding the function setTimeout() to the function observeImage() we created in the section above.

Likewise let's implement even this idea of a timeout in the code below. It'll operate quite the same way as did the timeout in the previous chapter's last section.

function loadImage(entries, observer) {
    // clear the previous timeout in stack
    clearTimeout(timeout);

    // create another timeout
    timeout = setTimeout(function() {
        entries.forEach(element => {
            if (element.isIntersecting) {
                element.target.src = element.target.dataset.src;
                io.unobserve(element.target);
            }
        });
    }, time);
}
The variable time here, in line 13, is the same variable time we saw in the previous chapter.

So when the function observeImage() will be called here, it will create a timeout whcih will fire after time milliseconds. If however, another call of observeImage() is made before this timer, the previous timeout will be removed from the stack and a new one will be put in for another time millseconds.

In this way we make sure that even for intersection observers, we are loading images only when they appear in the viewport for a certain amount of time.

Intersection Observer Lazy Loader

And so this concludes this whole lazy loading tutorial. Give a big congradulations to yourself.