Lazy loading without jQuery using the Intersection Observer API_

July 02, 2021 @11:15

I have a pair of galleries on the website that are generated by my simple-gallery Python program. I created it back in 2010 and in 2016 I added lazy loading of thumbnails using jQuery.lazyload. This seemed reasonable to me at the time. These days I prefer to not use any external JavaScript libraries unless I have to and then I try to only use ones that are reasonably self-contained (because let us not forget that the JS ecosystem has a history of being a Zork-like maze of twisty passages all alike.) So while making some other much needed updates (a lot of Python 2 to Python 3 refactoring) I set about taking some JavaScript I wrote for a different project and bashing it into something more generic.

Be kind, Rewind.

Part of the design of the original loader that I liked was the nice degradation in the case of a lack of JavaScript support. The actual IMG tags are encapsulated inside NOSCRIPT tags so that if you visit the site without JavaScript enabled it will actually work... albeit slightly slower. While reviewing, I decided update do two things.

Firstly I wanted to modernize the visibility detection. The original script uses getBoundingClientRect and innerHeight and innerWidth to calculate an element's visibility. Secondly I wanted to untangle the hard-coded use of PhotoSwipe from it.

With that in mind I started out bringing over the framework. We start by gathering up all the NOSCRIPT tags and processing them.

class LazyLoader {
    constructor() {
        this.preloadImgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
        const noscripts = document.body.querySelectorAll('noscript');
        if (! noscripts) { return; }

        const images = [];
        Array.from(noscripts).forEach((el) => {
            const img = this.processImage(el);
            images.push(img);
        });
    }

    processImage(el) {
        /* The child of a NOSCRIPT Element is a TextNode... */
        const dom = (new DOMParser()).parseFromString(
            el.textContent,
            'text/html'
        );

        if (! dom || ! dom.body.firstChild) { return; }

        const img = document.importNode(dom.body.firstChild);
        if (img.nodeName !== 'IMG') { return; }

        const parentEl = el.parentElement;
        const src = img.getAttribute('src');
        const srcset = img.getAttribute('srcset');

        img.src = this.preloadImgSrc;
        img.dataset.src = src;

        if (srcset) {
            img.removeAttribute('srcset');
            img.dataset.srcset = srcset;
        }

        parentEl.replaceChild(img, el);
        return parentEl;
    }
}

For each NOSCRIPT tag with an IMG in it I replace the NOSCRIPT tag with the IMG tag but instead of leaving the IMG tag's src attribute set I save it (and the srcset attribute if it exists) as a data attribute. I then set the src to a 1x1 pixel transparent GIF image for size and speed. The image came from this StackOverflow thread.

I've got my eye on you.

Now that we have the images removed, the page should render with blanks where the images should be. To trigger the loading we will use the Intersection Observer API to determine if the image elements are on the screen. To do this I collect the elements as we work on them and then attach an observer to them. The observer is configured with a bottom margin (so they trigger loading prior to coming on the screen) and a callback to do the loading.

this.observer = new IntersectionObserver((entries, observer) => {
        this.visibilityChanged(entries, observer);
    },
    { 'rootMargin': '0px 0px 200px 0px' }
);

After I call this.processImage() I call this.observer.observe() on the return value. As usual, the real work gets done in the callback.

visibilityChanged(entries, observer) {
    entries.forEach((entry) => {
        if (! entry.isIntersecting) { return; }

        const img = entry.target.children[0];
        img.src = img.dataset.src

        if ('srcset' in img.dataset && img.dataset.srcset) {
            img.srcset = img.dataset.srcset;
        }
    });
}

The callback just flips the src and srcset attributes back to the way they were. It checks to see if we are coming into view via the isIntersecting property of the IntersectionObserverEntry that is passed to us, since I only load once upon scroll-in.

Tell your friends

In the original version of the loader I hooked into PhotoSwipe directly from the LazyLoader object. This was fine for that use case but I wanted this to be a bit more generic so I added a simple callback based event system. This is a super popular JavaScript pattern so I won't belabor exactly how it works, but basically a caller can register a callback to get executed during certain events. The two ones I found useful are load which happens when we load the actual image and click which is fired when the A tag containing the IMG element is clicked, which gives the caller a chance to intercept the click and do something (like opening a lightbox) but degrades in the case of JavaScript being disabled.

Anyway, all you need is a way to register a callback and a way to call the callbacks. I call those methods on and fire.

on(eventName, callback) {
    if (eventName in this.validEvents === false) { return; }
    if (typeof callback !== 'function') { return; }
    if (! Object.prototype.hasOwnProperty(this.callbacks, eventName)) {
        this.callbacks[eventName] = [];
    }

    if (callback in this.callbacks[eventName]) { return; }
    this.callbacks[eventName].push(callback);
}


fire(eventName, element) {
    if (eventName in this.validEvents === false) { return; }
    if (eventName in this.callbacks === false) { return; }

    for (const func in this.callbacks[eventName]) {
        try {
            func(element);
        } catch (e) {
            console.error('callback failed: ' + e);
        }
    }
}

I added a call to fire in the this.visibilityChanged() function and bound a call to it in a click handler in this.processImage() as I construct the original IMG tag. Now a simple call to this.on() can register a callback for either event.

Stopping DAS BLINKENLIGHTS

At this point I had a working LazyLoader... sort of. Depending on how I tested I would encounter issues where all the images would load. It turns out that because the constructor was getting called early in page load, when we changed the images out for our 1x1 transparent GIF we were causing them to all be on the screen. That caused all the image elements to get passed to visibilityChanged and be loaded. This caused a repaint because the sizes changed and well it was too late at that point.

The fix was twofold, first I delayed the attachment of the IntersectionObserver until the DOM was at least interactive, meaning we would have had a first paint already. Second I specified the image size in CSS so the initial calculations would reserve the space we needed.

if (document.readyState === 'complete' ||
    document.readyState === 'interactive') {
    this.attach(images);
} else {
    document.addEventListener('readystatechange', (evt) => {
        if (evt.target.readyState === 'complete' ||
            this.attach(images);
        }
     });
}

In the end, this is what I came up with and is what is running on both my photo and screenshot galleries.

/* lazyloader.js (c) 2021 Matthew J. Ernisse <matt@going-flying.com>
 * All Rights Reserved.
 *
 * Redistribution and use in source and binary forms,
 * with or without modification, are permitted provided
 * that the following conditions are met:
 *
 *    * Redistributions of source code must retain the
 *      above copyright notice, this list of conditions
 *      and the following disclaimer.
 *    * Redistributions in binary form must reproduce
 *      the above copyright notice, this list of conditions
 *      and the following disclaimer in the documentation
 *      and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
 * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
 * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
 * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
 * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
'use strict';

class LazyLoader {
    constructor() {
        this.callbacks = {};
        /*
         * Call the callback if 10% of the element is within
         * 20px of the bottom of the viewport.
         */
        this.observer = new IntersectionObserver(
            (entries, observer) => {
                this.visibilityChanged(entries, observer);
            },
            { 'rootMargin': '0px 0px 200px 0px' }
        );
        /*
         * Smallest data URI image possible for a transparent image
         * @see http://stackoverflow.com/questions/6018611/smallest-data-uri-image-possible-for-a-transparent-image
         */
        this.preloadImgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
        this.validEvents = ['click', 'load'];

        const noscripts = document.body.querySelectorAll('noscript');
        if (! noscripts) {
            console.log('no NOSCRIPT blocks, not loading.');
            return;
        }

        const images = [];
        Array.from(noscripts).forEach((el) => {
            const img = this.processImage(el);
            images.push(img);
        });

        if (document.readyState === 'complete' ||
            document.readyState === 'interactive') {
            this.attach(images);
        } else {
            document.addEventListener('readystatechange', (evt) => {
                if (evt.target.readyState === 'complete' ||
                    evt.target.readyState === 'interactive') {
                    this.attach(images);
                }
            });
        }
    }

    attach(images) {
        images.forEach((i) => {
            this.observer.observe(i);
        });
    }

    fire(eventName, element) {
        if (eventName in this.validEvents === false) {
            return;
        }

        if (eventName in this.callbacks === false) {
            return;
        }

        for (const func in this.callbacks[eventName]) {
            try {
                func(element);
            } catch (e) {
                console.error('callback failed: ' + e);
            }
        }
    }

    /**
     * Register a callback handler for an event.  Currently the available
     * events are:
     * click: when the A containing the image is clicked on.
     * load: when the placeholder image is swapped for the real image.
     */
    on(eventName, callback) {
        if (eventName in this.validEvents === false) {
            return;
        }

        if (typeof callback !== 'function') {
            console.error('callback is not a function.');
            return;
        }

        if (! Object.prototype.hasOwnProperty(this.callbacks, eventName)) {
            this.callbacks[eventName] = [];
        }

        if (callback in this.callbacks[eventName]) {
            return;
        }

        this.callbacks[eventName].push(callback);
    }

    /**
     * Prepare the NOSCRIPT element for switching to an image.
     */
    processImage(el) {
        /* The child of a NOSCRIPT Element is a TextNode... */
        const dom = (new DOMParser()).parseFromString(
            el.textContent,
            'text/html'
        );

        if (! dom || ! dom.body.firstChild) {
            return;
        }

        const img = document.importNode(dom.body.firstChild);
        if (img.nodeName !== 'IMG') {
            return;
        }

        const parentEl = el.parentElement;
        const src = img.getAttribute('src');
        const srcset = img.getAttribute('srcset');

        parentEl.addEventListener('click', (evt) => {
            this.fire('click', evt);
        });

        img.src = this.preloadImgSrc;
        img.dataset.src = src;

        if (srcset) {
            img.removeAttribute('srcset');
            img.dataset.srcset = srcset;
        }

        /* overwrite the NOSCRIPT element with the IMG element. */
        parentEl.replaceChild(img, el);
        return parentEl;
    }

    /**
     * Callback for IntersectionObserver.
     */
    visibilityChanged(entries, observer) {
        entries.forEach((entry) => {
            // Now we actually swap the noscript for the image and
            // when done, fire the event.
            if (! entry.isIntersecting) {
                return;
            }

            const img = entry.target.children[0];
            img.src = img.dataset.src

            if ('srcset' in img.dataset && img.dataset.srcset) {
                img.srcset = img.dataset.srcset;
            }

            this.fire('load', entry.target);
        });
    }
}

export { LazyLoader };

Conclusion

A look at the with JavaScript versus the without JavaScript waterfalls validates the need for lazy loading on image heavy pages (20 requests in 1.07 seconds versus 74 requests and 5.66 seconds). Being able to function in a non-JavaScript environment is useful to me, if only because of my hard learnedcurmugeonly views on technology. More broadly re-writing the loader from scratch in vanilla JavaScript meant I saved loading jQuery and a plugin, which means 2 requests to a CDN (and all the privacy implications) and several hundred KB was replaced with a little more than 100 lines of code.

I'd call that a win.

Subscribe via RSS. Send me a comment.