Skip to content

Instantly share code, notes, and snippets.

@LionsAd
Last active July 4, 2022 16:40
Show Gist options
  • Save LionsAd/d6d490161e5d07bfa6d14cd711bc528e to your computer and use it in GitHub Desktop.
Save LionsAd/d6d490161e5d07bfa6d14cd711bc528e to your computer and use it in GitHub Desktop.
Lazy Load Javascript in Order

Introduction

Lazy loading scripts is a very good idea, because Javascript should be a progressive enhancement and not mandatory.

I know that's old school, but I still believe in that vision of the Web.

Jake Archibald tried this in 2013 (https://www.html5rocks.com/en/tutorials/speed/script-loading/) and kinda resigned "That. At the end of the body element.".

Much has changed since then and the programmatic way of loading the scripts as async works now. (https://www.html5rocks.com/en/tutorials/speed/script-loading/#toc-dom-rescue):

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);

as all browsers understand async now.

It has one large drawback however - you need to declare all your scripts as arrays and load them that way.

Template element to lazy load scripts in order

Fortunately today in 2019 we can do better, enter the template element:

<template id="lazy-scripts">
  <script src="async.js" async />
  <script src="//other-domain.com/1.js" />
  <script src="2.js" />
</template>

The template element allows us one crucial thing, we can change the DOM (and hence the script behavior) before we insert it and scripts are active in a template.

Therefore we can do:

      var lazyScripts = document.getElementById('lazy-scripts');

      var scripts = lazyScripts.content.querySelectorAll('script');
      for (var index = 0; index < scripts.length; index++) {
        var script = scripts[index];
        if (script.getAttribute('async') === null) {
          script.async = false;
        }
      }

      lazyScripts.parentNode.append(lazyScripts.content);

and that's it!

And now all our scripts that are part of the teamplate should be loaded outside of the main render thread. (which is what Jake Archibald wanted to achive)

However I want to load them even later - once the Browser has displayed and rendered the page already.

Lazy loading

To truly lazy load the scripts, we can now do:

    function loadLazyScripts() {
      var lazyScripts = document.getElementById('lazy-scripts');

      var scripts = lazyScripts.content.querySelectorAll('script');
      for (var index = 0; index < scripts.length; index++) {
        var script = scripts[index];
        if (script.getAttribute('async') === null) {
          script.async = false;
        }
      }

      lazyScripts.parentNode.append(lazyScripts.content);
    }

    // Let's wait till the page is loaded for true progressive enhancement.
    window.addEventListener("load", loadLazyScripts);

So now all scripts are lazy loaded after the main page content has displayed.

And this worked out of the Box in Chrome, Safari, Firefox, hence my hope is high that it will work correctly in all browsers that support the template element.

For those that don't support it, they should just execute the scripts directly => BC compatible.

For old code that uses jQuery - it will automatically work as the document.ready event fires once the scripts are executed.

Problems

Only problematic are:

  • Inline scripts (can be inserted after all scripts have been loaded)
  • Scripts that depend on DOMContentLoaded directly -- as that has fired already (e.g. Drupal.attachBehaviors()), but it's possible to fire DOMContentLoaded again.

But besides those script-inherent problems, which might need some refactoring, this works like a charm.

To support that e.g. the following code would work:

    window.lazyScriptsLoaded = false;

    function loadLazyScripts() {
      var lazyScripts = document.getElementById('lazy-scripts');

      var scripts = lazyScripts.content.querySelectorAll('script');
      var length = scripts.length;
      var loadedScripts = 0;

      var scriptLoadCB = function () {
        loadedScripts++;
        if (loaded >= length) {
          // Trigger inline scripts
          var inlineScripts = document.getElementById('inline-scripts');
          inlineScripts.parentNode.append(inlineScripts.content);

          window.lazyScriptsLoaded = true;
          // Trigger jQuery document.ready, DOMContentLoaded and other things here.
        }
      }


      for (var index = 0; index < scripts.length; index++) {
        var script = scripts[index];
        if (script.getAttribute('async') === null) {
          script.async = false;
        }
        if (script.getAttribute('src') != null) {
          script.onload = function() {
            scriptLoadCB();
          };
        }
        else {
          // Or throw an Exception that inline scripts must not be part of lazy scripts, or ...
          length--;
        }
      }

      lazyScripts.parentNode.append(lazyScripts.content);
    }

    window.addEventListener("load", loadLazyScripts);

And with that we can have truly lazy loaded scripts in order without doing more than putting two template elements in the generated HTML to distinguish "necessary javascript", "non-render blocking javascript" and "progressive javascript".

Isn't that nice?

Comments welcome!

(The same technique might even be possible without template element using + range writing into a div + query SelectorAll, but I think those browsers that don't support template should just fallback to the normal behavior.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment