Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove lazysizes and use browser-default lazyloading #2147

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

bookernath
Copy link
Contributor

@bookernath bookernath commented Nov 30, 2021

Testing out whether removing JS-based lazyloading via lazysizes improves page speed metrics.

Our use of lazysizes was very useful when originally implemented, when browser lazyloading did not have significant adoption. With browser adoption now at 75%, we can consider moving to the native solution and reduce our use of JS.

@bigbot
Copy link

bigbot commented Nov 30, 2021

Autotagging @bigcommerce/themes-team

@bookernath
Copy link
Contributor Author

⚠️ so far, this seems worse for performance, because the browser is making bad srcset choices and downloading images much larger than they need to be. I believe this is because the image sizing is not clear to the browser at download time, so it chooses a large size.

This can probably be fixed by either using a sizes attribute (lazysizes was doing this automagically before), or using stricter CSS.

@Tiggerito
Copy link
Contributor

Not sure if it is related, but I found that Chromes mobile emulation had issues with picking the right srcset image. I raised a bug:

https://bugs.chromium.org/p/chromium/issues/detail?id=1246772#c3

My solution was to avoid the mobile mode and just manually adjust the window/viewport size.

@Tiggerito
Copy link
Contributor

It looks like the lack of the sizes attribute is the problem. I hacked in a hand coded sizes value and it then chose the right image.

Maybe the lazysizes can still work for us.

@Tiggerito
Copy link
Contributor

I've also written an article on a way to use the native lazyload while still being able to use a placeholder image. either a spinner for all images or LQIPs for each.

https://bigcommerce.websiteadvantage.com.au/tag-rocket/articles/improving-image-loading-without-javascript/

On a side note, lazy sizing triggers on the onload event which is delayed by all sorts of things, including tracking code. If the primary image of a page is set to lazyload using it, the LCP for the page can get really bad.

Native lazyload is quite smart and will instantly load the image if it is in the viewport, no waiting for the load event to do it.

I've also noticed on a lot of stores that the lazysizes method often means you see the spinner as you scroll, while native lazyload seems to handle this a lot better.

@bookernath
Copy link
Contributor Author

@Tiggerito I've made further updates, now I set the sizes attributes for all responsive images, using information available to me through the theme where possible.

You can see the updates here: https://test-store686.mybigcommerce.com/shop-all/

Interested in your thoughts.

@bookernath
Copy link
Contributor Author

bookernath commented Dec 15, 2021

I am seeing consistently better performance on the Lighthouse mobile test.

https://cornerstone-light-demo.mybigcommerce.com/shop-all/ is 63-83 (wide variation based on luck-of-the-draw with JS execution, I bet)
https://test-store686.mybigcommerce.com/shop-all/ is 77-85, usually 85. I see 87 on the hosted test.

I'll remove the WIP label and request for review from @bigcommerce/themes-team - FYI @bc-as

@bookernath bookernath changed the title [WIP/experiment] Remove lazysizes and use browser-default lazyloading Remove lazysizes and use browser-default lazyloading Dec 15, 2021
@Tiggerito
Copy link
Contributor

I couldn't get it to trigger lazy loading. I think the native system has quite a big window to start loading images off screen. Even on a narrow display they all loaded at once.

The main thing is that the visible images started loading as soon as the CSS file was loaded.

It was nice to see that the browser put the visible product images on high importance and the rest on low.

On that page the sizes attribute did it's job and all images are 320px.

The mobile emulation bug I mentioned is still affecting Lighthouse. When fixed the "Properly size images" issue should go away and the score should go up.

LCP can still be improved, but maybe that can be later (the other issue): Removing lazy load of the main images and adding a preload have those images load even sooner.

I suspect that we don't need to remove the native lazy load as it's quite smart. And a preload would ensure it loads early anyhow. Removing it may be to just gain some points and remove a warning.

@sacr3dc0w
Copy link
Contributor

sacr3dc0w commented Dec 22, 2021

This PR breaks lazy loading entirely for content posting through the backend (product, blog, category, etc) if someone attached the lazyload class to an image / iframe along with a data-src attribute.

Screen Shot 2021-12-22 at 7 30 45 AM

@Tiggerito
Copy link
Contributor

This PR breaks lazy loading entirely for content posting through the backend (product, blog, category, etc) if someone attached the lazyload class to an image / iframe along with a data-src attribute.

Screen Shot 2021-12-22 at 7 30 45 AM

Good point. Does BC have a standard practice for dealing with deprecating features like this?

One possible solution is to add a small script that looks for img/iframe tags with the lazyload class and move the data-src attribute to src.

@Tiggerito
Copy link
Contributor

The Google dev team provide an example of using the inbuilt loading attribute if available and doing a fall back to lazysizes if not.

https://web.dev/browser-level-image-lazy-loading/#how-do-i-handle-browsers-that-don't-yet-support-lazy-loading

Something like that may work well and add lazy loading support for Safari.

A slight alteration could be done to detect images with the lazyloading class but not the loading attribute and add the attribute so lazyloading still works on browsers that support the attribute.

At some point the lazysizes part could be dropped.

This hack does mean that image src values won't be set until around the DCL event, which means initially visible images would be delayed. It would be nice if the server could detect if lazyloading is supported and directly set the src (User Agent?). The main image should not be lazyloaded anyhow. Maybe directly set the src for images that are likely to show in the viewport so only native lazyloading is supported for them.

@Tiggerito
Copy link
Contributor

@Tiggerito
Copy link
Contributor

Over the break I did a lot of work and testing related to this. And came up with a variant of this solution. Some notes:

I think we need to support lazysizes until at least Safari supports native lazy-loading and so that manually added images don't break.

I did an extensive rewrite of responsive-img with that in mind, plus:

I implemented LQIP using CSS and a background image. This means it still works with native lazy-loading and it has some technical advantages over the src swapping technique (like I can preload the LQIP image). The down side is it requires a little bit of CSS to make the background image line up with the main image, and its more at risk of display issues.

I re-structured the properties you could set for responsive-img to make it more flexible, while keeping backwards compatibility with the lazyload parameter.

The 'loading' parameter lets you decide the lazy-loading mode. 'auto' (default), 'lazy', 'eager'

I added an 'importance' parameter ('auto' (default), 'high', 'low'). This is a future attribute (closed Chrome beta at the moment) that can be used to get an image to load earlier. Kind of the opposite of lazy-load and very similar to adding a preload for the image.

LQIP is enabled via the 'placeholder' parameter: 'auto' (default), 'lqip'.

I added a 'lazysizes' parameter ('auto' (default), 'disabled') to force the use if native lazy-loading which would be faster.

I also implemented a 'sizes' parameter with a slight twist. If the size specified was a single pixel with it would optimise the generated html to provide just that size of image. No need to provide a whole list of images when you've already said which one to use. This will reduce a lot of code bloat.

On the functionality, I added some more classes to identify things. Like 'default-image' if it had to fall back to the default image.

Here's the code I am currently testing.

{{!--  
    Image classes
    "responsive-img" - applied to all img tags generated by this code. Use this class to control how the background looks before any image is loaded.
    "loading-auto" - applied when no loading mode is specified. i.e. not lazy. Same as the old 'disabled' setting.
    "loading-lazy" - applied when lazy-loading is enabled
    "placeholder-auto" - a transparent image is used as the placeholder before the main image loads. e.g. not LQIP
    "placeholder-lqip" - applied when LQIP is enabled. Use this to make sure the LQIP background-image is inline with the main image.
    "lazysizes-auto" - Lazysizes will be used if native lazy-loading is not supported by the browser
    "lazysizes-disabled" - Lazysizes will not be used even if native lazy-loading is not supported by the browser
    "lazyload" - applied when lazy-loading and Lazysizes is enabled so that Lazysizes can target them automatically.

    LQIP
    The Low Quality Image Placeholder is now implemented via CSS using background-image. This is to make it compatible with a browsers native lazy-loading.
    Add the following style to your theme so that the LQIP image aligns well with the main image that replaces it:

    <style>          
        img.placeholder-lqip {
            background-repeat: no-repeat;
            background-size: contain;
            background-position: center;
        }
        img.placeholder-lqip.normalloaded, img.placeholder-lqip.lazyloaded {
            background-image: none !important;
        }
    </style>

    The use of CSS for LQIP may conflict with things. Like the main product image gets replaced by gallery images that may not fully cover the LQIP. The base.html script 
    can fix that by making sure the 'lazyloaded' or 'normalloaded' class is added to the image once it has been loaded. And the above CSS removes the background image.

    I also noticed the product gallery system is very slow at loading the alternate images. prefetch may help there. 
    However it seems the mechanism will reload the large image every time it is selected, even if the image has been previously loaded?

    Lazysizes
    By default the Lazysizes library is used to lazy-load an image.
    It is possible to switch to using the browsers native lazy-loading mechanism when supported.
    The best way at the moment is to include a script to the head of the base.html that dynamically converts images to using the native lazy-loading when possible.
    native lazy-loading has a few requirements:
    'sizes' must be specified. This is so the native system can decide which responsive image to use.
    (Lazysizes dynamically works out the 'sizes' value so does not require it to be set)
    If native lazy-loading is not available (e.g. Safari) the system will stick to Lazysizes.
    You can disable the Lazysizes fallback for each image using the lazysizes='disabled'
    With the script added you can remove the standard Lazysizes loading code so that it is only loaded if required.

    The use of CSS LQIP and native lazy-loading means that the user sees a gradual quality improvement as the full size image is loaded.
    With Lazysizes an image swap is done so the image goes from low quality to high quality in the last moment.

    Arguments
    'loading': 'auto' (default), 'lazy', 'eager'
       This sets the img loading attribute. Use 'lazy' for images that are not typically initially seen.
       'auto' leaves it up to the browser to decide how to load the image. Typically this means it is loaded as soon as possible. Good for top of the page images.
       'lazy' is good for images that are lower down on the page, especially when the page is long and contains lots of images
       'eager' is good for lower down images that for some reason need to be loaded early on.
       If 'lazy' is set, Lazysizes or the browsers native lazy-loading mechanism will be used.

    'importance': 'auto' (default), 'high', 'low'
       In the future importance="high" will cause the browser to load an image much sooner.
       This is very good for images that are initially visible on a page. 
       It is very good at reducing the Core Web Vitals LCP score.
       Importance of 'high' will also cause the LQIP image to be preloaded at a high importance level.
       
    'placeholder': 'auto' (default), 'lqip'
       By default a blank image is used before the main image is loaded. The 'placeholder-auto' class can be used to decide how it looks.
       'lqip' means a Low Quality Image Placeholder is loaded fist... 
       On slower connections the user will initially see a blurry version of the image while the full version of the image loads.
       This can provide a better user experience but does add the loading of an extra small image.
       It's of little value to use 'lqip' on smaller images (LQIP images are 80 pixels wide) or images that typically load before the page is rendered (preloaded or importance high).

    'sizes':
       https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
       The sizes attribute value.  
       The sizes attribute defines how the browser should pick the correct image to use as defined in srcset.          
       If sizes is not supplied, the system will use Lazysizes which calculates 'sizes' for you.
       If 'sizes' is set to a single pixel width (e.g. '500px') only a single image of that width will be provided which reduces code bloat and complexity. 

    'lazysizes': 'auto' (default), 'disabled'
       By default Lazysizes will be used.
       The optional base.html code will dynamically switch to using native lazy-loading if 'sizes' or 'x1' are set and the browser supports it.
       If set to 'disabled' then native lazy-loading is used by default, without the need for the base.html code. Browsers that do not support native lazy-loading will load the images as normal. i.e. no lazy-loading.
       Lazysizes generates the 'sizes' attribute automatically which would sometimes be more accurate than when we manually set 'sizes'
       However Lazysizes needs to wait for rendering to do this, meaning image loading is delayed and can't be reliably preloaded.
            
    'fallback_size':
        This is the size to use for this image in legacy browsers that do not support srcset.
        Can be defined as a pixel bounding-box size (e.g. "123x123) or inherent width (e.g. "123w").
    
    'class':
        Extra CSS classes to add to the image, e.g. "card-image".    
    
    'otherAttributes':
        Any other HTML attributes you want on the img tag, for example "height='100' width='100'"
        Specifying height and width can reduce Layout Shifts. They can also be specified in CSS
    
    'default_image':
        The default image to use if `image` is undefined or falsy. Should be a theme asset, usually defined as a relative
        path in the theme config. If a default image is not provided, you'll get an blank image if the primary
        image is undefined.
        The default image is never lazy-loaded and does not use a LQIP image
    
    'lqip_size':
        If you want to specify a particular size for the LQIP image, you can do so with this argument. 
        A default of 80 pixels wide will be used otherwise.
        
    Backward compatibility:
    The old 'lazyload' parameter values will map to:
    'lazyload' - loading='lazy'
    'lazyload-lqip' - loading='lazy', placeholder='lqip'
    'disabled' - default values
    The new parameters override the 'lazyload' parameter
    
      Ideas
      Ability to use browser setting for Save-Data or network speed to disable LQIP and maybe even reduce image sizes.
    --}}

    {{#if (isString sizes) '===' false}}
    {{assignVar 'responsiveSizes' (get 'string' sizes)}}
    {{else if sizes}}
    {{assignVar 'responsiveSizes' sizes}}
    {{else}}
    {{assignVar 'responsiveSizes' 'auto'}}
    {{/if}}

    {{#all (if (occurrences (getVar 'responsiveSizes') ',') '===' 0) (if (occurrences (getVar 'responsiveSizes') 'p') '===' 1)}}
    {{assignVar 'responsive1x' (get 'string' (concat (first (split (getVar 'responsiveSizes') 'p')) 'w'))}}
    {{else}}
    {{assignVar 'responsive1x' '-'}}
    {{/all}}

    {{#if loading}}
    {{assignVar 'responsiveLoading' loading}}
    {{else if lazyload '===' 'lazyload'}}
    {{assignVar 'responsiveLoading' 'lazy'}}
    {{else if lazyload '===' 'lazyload+lqip'}}
    {{assignVar 'responsiveLoading' 'lazy'}}
    {{else}}
    {{assignVar 'responsiveLoading' 'auto'}}
    {{/if}}

    {{#if placeholder}}
    {{assignVar 'responsivePlaceholder' placeholder}}
    {{else if lazyload '===' 'lazyload+lqip'}}
    {{assignVar 'responsivePlaceholder' 'lqip'}}
    {{else}}
    {{assignVar 'responsivePlaceholder' 'auto'}}
    {{/if}}

    {{#all (if (getVar 'responsivePlaceholder') '===' 'lqip') (if importance '===' 'high') (if image)}}  
    <link rel="preload" as="image" href="{{getImageSrcset image 1x=(default lqip_size '80w')}}" importance="high">
    {{/all}}

    <img  
    {{#if image}}
         src="{{getImageSrcset image 1x=(default fallback_size '160w')}}" alt="{{image.alt}}" 

         {{#if importance}}importance="{{importance}}"{{/if}} 
      
         {{#if (getVar 'responsiveLoading') '===' 'lazy'}}
         loading="lazy"
         {{#if lazysizes '!==' 'disabled'}}
         data-sizes="auto"
         {{!-- always this so no default image if srcset is supported --}}
         srcset="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" 
         {{#if (getVar 'responsive1x') '!==' '-'}}
         data-srcset="{{getImageSrcset image 1x=(getVar 'responsive1x')}}"
         {{else}}       
         data-srcset="{{getImageSrcset image use_default_sizes=true}}"{{#if sizes}} sizes="{{sizes}}"{{/if}}
         {{/if}}
         {{/if}}
         {{else}}
         {{#if (getVar 'responsiveLoading') '!==' 'auto'}}
         loading="{{getVar 'responsiveLoading'}}"
         {{/if}}
         {{#if (getVar 'responsive1x') '!==' '-'}}
         srcset="{{getImageSrcset image 1x=(getVar 'responsive1x')}}"
         {{else}}       
         srcset="{{getImageSrcset image use_default_sizes=true }}"{{#if sizes}} sizes="{{sizes}}"{{/if}}
         {{/if}}
         {{/if}}

         class="responsive-img{{#if class}} {{class}}{{/if}} lazysizes-{{default lazysizes 'auto'}}{{#if (getVar 'responsiveLoading') '===' 'lazy'}} loading-lazy{{#if lazysizes '!==' 'disabled' }} lazyload{{/if}}{{else}} loading-{{getVar 'responsiveLoading'}}{{/if}}{{#if (getVar 'responsivePlaceholder') '===' 'lqip'}} placeholder-lqip" style="background-image: url('{{getImageSrcset image 1x=(default lqip_size '80w')}}')"{{else}} placeholder-auto"{{/if}}
    
    {{else if default_image}}
    src="{{cdn default_image}}" 
    alt="{{lang 'products.card_default_image_alt'}}" 
    class="responsive-img loading-auto placeholder-auto default-image{{#if class}} {{class}}{{/if}}"
    {{else}}
    src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" 
    alt="" 
    class="responsive-img loading-auto placeholder-auto no-image{{#if class}} {{class}}{{/if}}"
    {{/if}}
    
    {{otherAttributes}} />
    

@Tiggerito
Copy link
Contributor

As my responsive-img solution still uses lazysizes I developed some code to dynamically convert img tags using lazysizes to use native lazy loading if it is available. It also only loaded the lazysizes script if required (the hard coded script tag should be removed). e.g. Lazysizes will be loaded and used on Safari, but not on most other browsers.

The script needs to be inline and in the head section. This is so that it can monitor img tags as they get added to the DOM and change them instantly. This means they get the benefits of native lazy loading instantly. e.g. if the image is in the viewport it starts loading asap.

The script also applies classes to mimic what lazysizes does, just in case people use them. And for fun I created a set of classes to track image loading when lazy loading is not enabled: normalloading, normalloaded

One improvement would be to host lazysizes on the BC CDN to remove a connection delay. I guess that was already done by the other script.

Here's my current code:

        {{!-- 
            Native lazy-loading with Lazysizes as a fallback
            Loads Lazysizes only if required
            Works bast with my modified responsive-img.html file
            Must be placed first inside the head section so that it's execution is not blocked
            Can Remove the 'Load Lazysizes' code already in this file as it is done here
            Can Remove the theme-bundle.head_async.js script as it only contained lazysizes which is now dynamically loaded if required
            May want to find a better source for the lazysizes script? Directly from the BC CDN would reduce a connection delay
            CSS files still delay building the DOM. So on page lazy-loading images only start loading once the CSS has loaded.
        --}}
        <script>
        !function(w,d){           
            var native = 'loading' in HTMLImageElement.prototype;

            var canGoNative = function(n) {
                // return false; // to disable the switch to native lazy-loading
                if (!native) return false;
                if (n.tagName !== 'IMG') return false; // only deal with img tags
                if (n.sizes) return true; // have sizes so can do it
                if (!n.dataset.srcset) return true; //  no srcset so only can be one size
                return !n.dataset.srcset.includes(','); // only one entry is safe
            }

            var loadLazySizes = function () {
                if(!w.lazySizesConfig) {
                    w.lazySizesConfig = w.lazySizesConfig || {};
                    w.lazySizesConfig.loadMode = 1;
                    var s = d.createElement('script');
                    s.src = 'https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.1.2/lazysizes.min.js';
                    d.getElementsByTagName('head')[0].appendChild(s);
                }
            }

            var goNative = function (n) {
                {{!-- These are for backwards compatibility with the old responsive-img.html --}}
                if(!n.loading) n.loading = "lazy";
                {{#if theme_settings.lazyload_mode '===' 'lazyload+lqip'}}
                if (!n.classList.contains('responsive-img')) {
                    if (n.srcset) { n.style.cssText += "background-image: url('" + n.srcset + "');" };
                }
                {{/if }}
                {{!-- END: These are for backwards compatibility --}}
                if (n.dataset.src) n.src = n.dataset.src;                                    
                if (n.dataset.srcset) n.srcset = n.dataset.srcset;
                if (n.dataset.sizes && n.dataset.sizes !== 'auto') n.sizes = n.dataset.sizes;
                n.classList.remove('lazyload');
                n.classList.add('lazyloading');
                n.onload=function(){n.classList.add('lazyloaded');n.classList.remove('lazyloading')};
            }

            var o = new MutationObserver(function(l) {
                l.forEach(function(m) {
                    m.addedNodes.forEach(function(n) {
                        if (n.nodeType === 1 ) {                            
                            if (n.classList.contains('lazyload')) {
                                if (canGoNative(n)) {
                                    goNative(n);
                                } else {
                                    loadLazySizes();
                                    if (!native) o.disconnect();
                                }
                            }
                            else if (n.tagName === 'IMG') {
                                n.classList.add('normalloading')
                                n.onload=function(){n.classList.add('normalloaded');n.classList.remove('normalloading')};
                            }
                        }                         
                    });
                });
            });
            o.observe(d.documentElement,{subtree: true,childList:true});
        }(window,document)
        </script>

@Tiggerito
Copy link
Contributor

And here is the CSS to make LQIP images work:

        {{!-- CSS based LQIP support
            My modified responsive-img.html supports LQIP by using CSS background-image
            This CSS is needed to make the LQIP image match the positioning of the real image 
        --}}
        <style>          
            img.placeholder-lqip {
                background-repeat: no-repeat;
                background-size: contain;
                background-position: center;
            }
            img.placeholder-lqip.normalloaded, img.placeholder-lqip.lazyloaded {
                background-image: none !important;
            }
        </style>

The second bit is to remove the background image once the main image was loaded. This was after seeing some issues with the product gallery feature that would swap images around and the background could show up. I already have a use for my new normalloaded class :-)

@steve-ross
Copy link
Contributor

steve-ross commented May 4, 2022

Oh Lort, this is a lot... I'm working on a version of the code that will test: vanilla lazyload, loading-attribute-polyfill vs lazysizes, and also swapping CDNs & using Cloudflare for image transformations (you'd get domain authority, etc).

It stinks that the only way to really test is to push things to a BC server and compare load times (and those seem to fluctuate based on time of day, etc)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants