Lazyload and Responsively Serve External Images In GatsbyJs

For the Gatsby version of my website, currently in development, I am serving all my images from Imagekit.io - a global image CDN. The reasons for doing this is so I will have the ultimate flexibility in how images are used within my site, which didn’t necessarily fit with what Gatsby has to offer especially when it came to how I wanted to position images within blog post content served from markdown files.

As I understand it, Gatsby Image has two methods of responsively resizing images:

  1. Fixed: Images that have a fixed width and height.
  2. Fluid: Images that stretch across a fluid container.

In my blog posts, I like to align my images (just take look at my post about my time in the Maldives) as it helps break the post up a bit. I won’t be able to achieve that look by the options provided in Gatsby. It’ll look all a little bit too stacked. The only option is to serve my images from Imagekit.io, which in the grand scheme isn’t a bad idea. I get the benefit of being able to transform images on the fly, optimisation (that can be customised through Imagekit.io dashboard) and fast delivery through its content-delivery network.

To meet my image requirements, I decided to develop a custom responsive image component that will perform the following:

  • Lazyload image when visible in viewport.
  • Ability to parse an array “srcset" sizes.
  • Set a default image width.
  • Render the image on page load in low resolution.

React Visibility Sensor

The component requires the use of "react-visibility-sensor” plugin to mimic the lazy loading functionality. The plugin notifies you when a component enters and exits the viewport. In our case, we only want the sensor to run once an image enters the viewport. By default, the sensor is always fired every time a block enters and exits the viewport, causing our image to constantly alternate between the small and large versions - something we don't want.

Thanks for a useful post by Mark Oskon, he provided a solution that extends upon the react-visibility-sensor plugin and allows us to turn off the sensor after the first reveal. I ported the code from Mark's solution in a newly created component housed in "/core/visibility-sensor.js", which I then reference into my LazyloadImage component:

import React, { Component } from "react";
import PropTypes from "prop-types";
import VSensor from "react-visibility-sensor";

class VisibilitySensor extends Component {
  state = {
    active: true
  };

  render() {
    const { active } = this.state;
    const { once, children, ...theRest } = this.props;
    return (
      <VSensor
        active={active}
        onChange={isVisible =>
          once &&
          isVisible &&
          this.setState({ active: false })
        }
        {...theRest}
      >
        {({ isVisible }) => children({ isVisible })}
      </VSensor>
    );
  }
}

VisibilitySensor.propTypes = {
  once: PropTypes.bool,
  children: PropTypes.func.isRequired
};

VisibilitySensor.defaultProps = {
  once: false
};

export default VisibilitySensor;

LazyloadImage Component

import PropTypes from "prop-types";
import React, { Component } from "react";
import VisibilitySensor from "../core/visibility-sensor"

class LazyloadImage extends Component {
    render() {
      let srcSetAttributeValue = "";
      let sanitiseImageSrc = this.props.src.replace(" ", "%20");

      // Iterate through the array of values from the "srcsetSizes" array property.
      if (this.props.srcsetSizes !== undefined && this.props.srcsetSizes.length > 0) {
        for (let i = 0; i < this.props.srcsetSizes.length; i++) {
          srcSetAttributeValue += `${sanitiseImageSrc}?tr=w-${this.props.srcsetSizes[i].imageWidth} ${this.props.srcsetSizes[i].viewPortWidth}w`;

          if (this.props.srcsetSizes.length - 1 !== i) {
            srcSetAttributeValue += ", ";
          }
        }
      }

      return (
          <VisibilitySensor key={sanitiseImageSrc} delayedCall={true} partialVisibility={true} once>
            {({isVisible}) =>
            <>
              {isVisible ? 
                <img src={`${sanitiseImageSrc}?tr=w-${this.props.widthPx}`} 
                      alt={this.props.alt}
                      sizes={this.props.sizes}
                      srcSet={srcSetAttributeValue} /> : 
                <img src={`${sanitiseImageSrc}?tr=w-${this.props.defaultWidthPx}`} 
                      alt={this.props.alt} />}
              </>
            }
          </VisibilitySensor>
      )
    }
}

LazyloadImage.propTypes = {
  alt: PropTypes.string,
  defaultWidthPx: PropTypes.number,
  sizes: PropTypes.string,
  src: PropTypes.string,
  srcsetSizes: PropTypes.arrayOf(
    PropTypes.shape({
      imageWidth: PropTypes.number,
      viewPortWidth: PropTypes.number
    })
  ),
  widthPx: PropTypes.number
}

LazyloadImage.defaultProps = {
  alt: ``,
  defaultWidthPx: 50,
  sizes: `50vw`,
  src: ``,
  widthPx: 50
}

export default LazyloadImage

Component In Use

The example below shows the LazyloadImage component used to serve a logo that will serve a different sized image with the following widths - 400, 300 and 200.

<LazyloadImage 
                src="https://ik.imagekit.io/surinderbhomra/Pages/logo-me.jpg" 
                widthPx={400} 
                srcsetSizes={[{ imageWidth: 400, viewPortWidth: 992 }, { imageWidth: 300, viewPortWidth: 768 }, { imageWidth: 200, viewPortWidth: 500 }]}
                alt="Surinder Logo" />

Useful Links

https://alligator.io/react/components-viewport-react-visibility-sensor/ https://imagekit.io/blog/lazy-loading-images-complete-guide/ https://www.sitepoint.com/how-to-build-responsive-images-with-srcset/

Responsive Images In ASP.NET: Converting Image Tag To Picture Tag

A picture tag allows us to serve different sized images based on different viewport breakpoints or pixel-ratios, resulting in better page load performance. Google's Pagespeed Insights negatively scores your site if responsive images aren't used. Pretty much all modern browsers support this markup and on the off chance it doesn't, an image fallback can be set.

Using the picture markup inside page templates is pretty straight-forward, but when it comes to CMS related content where HTML editors only accommodate image tags, it's really difficult to get someone like a client to add this form of markup. So the only workaround is to transform any image tag into a picture tag at code-level.

Code: ConvertImageToPictureTag Extension Method

The ConvertImageToPictureTag method will perform the following tasks:

  1. Loop through all image tags.
  2. Get the URL of the image from the "src" attribute.
  3. Get other attributes such as "alt" and "style".
  4. Generate picture markup and add as many source elements based on the viewport breakpoints required, apply the URL of the image, style and alt text.
  5. Replace the original image tag with the new picture tag.

The ConvertImageToPictureTag code uses HtmlAgilityPack, making it very easy to loop through all HTML nodes and manipulate the markup. In addition, this implementation relies on a lightweight client-side JavaScript plugin - lazysizes. The lazysizes plugin will delay the loading of the higher resolution image based on the viewport rules in the picture tag until the image is scrolled into view.

using HtmlAgilityPack;
using Site.Common.Kentico;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Text;
using System.Web;

namespace SurinderBhomra.Common.Extensions
{
    public static class ContentManipulatorExtensions
    {
        /// <summary>
        /// Transforms all image tags to a picture tag inside parsed HTML.
        /// All source image URL's need to contain a "width" query parameter in order to have a resize starting point.
        /// </summary>
        /// <param name="content"></param>
        /// <param name="percentageReduction"></param>
        /// <param name="minimumWidth">The minimum width an image has to be to warrant resizing.</param>
        /// <param name="viewPorts"></param>
        /// <returns></returns>
        public static string ConvertImageToPictureTag(this string content, int percentageReduction = 10, int minimumWidth = 200, params int[] viewPorts)
        {
            if (viewPorts?.Length == 0)
                throw new Exception("Viewport parameter is required.");

            if (!string.IsNullOrEmpty(content))
            {
                //Create a new document parser object.
                HtmlDocument document = new HtmlDocument();

                //Load the content.
                document.LoadHtml(content);

                //Get all image tags.
                List<HtmlNode> imageNodes = document.DocumentNode.Descendants("img").ToList();
                
                if (imageNodes.Any())
                {
                    // Loop through all image tags.
                    foreach (HtmlNode imgNode in imageNodes)
                    {
                        // Make sure there is an image source and it is not externally linked.
                        if (imgNode.Attributes.Contains("src") && !imgNode.Attributes["src"].Value.StartsWith("http", StringComparison.Ordinal))
                        {
                            #region Image Attributes - src, class, alt, style
                            
                            string imageSrc = imgNode.Attributes["src"].Value.Replace("~", string.Empty);
                            string imageClass = imgNode.Attributes.Contains("class") ? imgNode.Attributes["class"].Value : string.Empty;
                            string imageAlt = imgNode.Attributes.Contains("alt") ? imgNode.Attributes["alt"].Value : string.Empty;
                            string imageStyle = imgNode.Attributes.Contains("style") ? imgNode.Attributes["style"].Value : string.Empty;

                            #endregion

                            #region If Image Source has a width query parameter, this will be used as the starting size to reduce images

                            int imageWidth = 0;

                            UriBuilder imageSrcUri = new UriBuilder($"http://www.surinderbhomra.com{imageSrc}");
                            NameValueCollection imageSrcQueryParams = HttpUtility.ParseQueryString(imageSrcUri.Query);

                            if (imageSrcQueryParams?.Count > 0 && !string.IsNullOrEmpty(imageSrcQueryParams["width"]))
                                imageWidth = int.Parse(imageSrcQueryParams["width"]);

                            // If there is no width parameter, then we cannot resize this image.
                            // Might be an older non-responsive image link.
                            if (imageWidth == 0 || imageWidth <= minimumWidth)
                                continue;

                            // Clear the query string from image source.
                            imageSrc = imageSrc.ClearQueryStrings();

                            #endregion

                            // Create picture tag.
                            HtmlNode pictureNode = document.CreateElement("picture");

                            if (!string.IsNullOrEmpty(imageStyle))
                                pictureNode.Attributes.Add("style", imageStyle);

                            #region Add multiple source tags

                            StringBuilder sourceHtml = new StringBuilder();

                            int newImageWidth = imageWidth;
                            for (int vp = 0; vp < viewPorts.Length; vp++)
                            {
                                int viewPort = viewPorts[vp];

                                // We do not not want to apply the percentage reduction to the first viewport size.
                                // The first image should always be the original size.
                                if (vp != 0)
                                    newImageWidth = newImageWidth - (newImageWidth * percentageReduction / 100);

                                sourceHtml.Append($"<source srcset=\"{imageSrc}?width={newImageWidth}\" data-srcset=\"{imageSrc}?width={newImageWidth}\" media=\"(min-width: {viewPort}px)\">");
                            }

                            // Add fallback image.
                            sourceHtml.Append($"<img src=\"{imageSrc}?width=50\" style=\"width: {imageWidth}px\" class=\"{imageClass} lazyload\" alt=\"{imageAlt}\" />");

                            pictureNode.InnerHtml = sourceHtml.ToString();

                            #endregion

                            // Replace the image node with the new picture node.
                            imgNode.ParentNode.ReplaceChild(pictureNode, imgNode);
                        }
                    }

                    return document.DocumentNode.OuterHtml;
                }
            }

            return content;
        }
    }
}

To use this extension, add this to any string containing HTML markup, as so:

// The HTML markup will generate responsive images using based on the following parameters:
// - Images to be resized in 10% increments.
// - Images have to be more than 200px wide.
// - Viewport sizes to take into consideration: 1000, 768, 300.
string contentWithResponsiveImages = myHtmlContent.ConvertImageToPictureTag(10, 200, 1000, 768, 300);

Sidenote

The code I've shown doesn't carry out any image resizing, you will need to integrate that yourself. Generally, any good content management platform will have the capability to serve responsive images. In my case, I use Kentico and can resize images by adding a "width" and/or "height" query parameter to the image URL.

In addition, all image URL's used inside an image tags "src" attribute requires a width query string parameter. The value of the width parameter will be the size the image in its largest form. Depending on the type of platform used, the URL structure to render image sizes might be different. This will be the only place where the code will need to be retrofitted to adapt to your own use case.