JS include async and dynamic

How do I integrate JS asynchronously when it is needed?

In order to position yourself better in search results, the speed of the website is an important aspect alongside the content. Anyone who has already worked on page speed optimization knows that this is often a laborious undertaking. Nowadays, websites are equipped with countless functionalities that are intended to make the user’s experience more pleasant and offer them a good experience. However, this also means that JavaScript (JS) is an important component of websites. However, the more JS is integrated into a page, the worse the speed of the page usually becomes. This is not always noticeable for the user, but it is all the more noticeable in Google’s pagespeed ratings.

There are countless ways to optimize the speed of a website. It is out of the scope of this article to cover them all here. However, a good effect can be achieved with the asynchronous integration of JS. The following describes one way of integrating JS modules asynchronously – and only when they are actually needed.

When does it make sense to integrate JS asynchronously?

It is always a good idea to integrate JS asynchronously if it is not required directly when the page is loaded. In other words, if the corresponding JS file contains functions that are not used directly when loading or displaying the above-the-fold content (content that is displayed within the viewport on initial loading and is directly visible to the user). However, JS with functions that are required in the above-the-fold content can also be integrated asynchronously if the asynchronous integration does not have a negative impact on the user or other SEO-relevant key figures (e.g. layout shifts). There is no general rule here, as every website is different and the relevance of the various JS functions used on the respective page is also completely different.

Excursus: JS modules

In the context of this article, JS modules are functionalities that are stored in separate JS files and can be used repeatedly in a wide variety of places. The following example code represents a module (in the form of a class) that adds a class to HTML elements as soon as they are scrolled into the viewport:

class FadeIn {
  #fadeInElement;

  constructor(fadeInElement) {
    this.#fadeInElement = fadeInElement;
  }

  #handleFadeIn() {
    if (this.#fadeInElement.getBoundingClientRect().top <= window.innerHeight * .95) {
      if (!this.#fadeInElement.classList.contains('ec-fade-in')) {
        this.#fadeInElement.classList.add('ec-fade-in');
      }

      window.removeEventListener('scroll', this.#handleFadeIn.bind(this));
    }
  }

  init() {
    if (this.#fadeInElement.getBoundingClientRect().top > window.innerHeight * .95) {
      window.addEventListener('scroll', this.#handleFadeIn.bind(this));
    } else {
      if (!this.#fadeInElement.classList.contains('ec-fade-in')) {
        this.#fadeInElement.classList.add('ec-fade-in');
      }
    }
  }
}

export default FadeIn;

As you can see, the JS file has an export function. This means that the file, or the class declared in it, can be imported and reused in other JS files. Here is an example of an import:

import FadeIn from './fade-in.js';

Anyone who works with npm packages has already seen and used such imports. This type of module import is often the only thing done. And a compiler (webpack or similar) then bundles the imported code into one or two JS files, which are then integrated directly in the head or at the end of the body tag of the website (possibly also asynchronously – but often synchronously). The following section uses the above example to describe one way of integrating such a module in a performance-optimized way.

Dynamic, asynchronous integration of JS modules

The above-mentioned example module (FadeIn class) should now be integrated asynchronously and only when it is needed. For those who need some quick code and no deeper explanation, here is the complete class for asynchronous, dynamic loading of JS modules (with an example of loading the FadeIn class). For everyone else, the code is broken down into its components and explained in more detail below.

class ModulesLoader {
  #loadedModules = {};
  #loadModuleOnScrollFunc;

  constructor() {
    this.#loadModuleOnScrollFunc = this.#loadModuleOnScroll.bind(this);
  }

  #loadModuleSource(moduleName) {
    return new Promise((resolve, reject) => {
      switch (moduleName) {
        case 'fade-in':
          import('./../modules/fadeIn.js').then(moduleSource => {
            this.#loadedModules = { ...this.#loadedModules, moduleName: moduleSource };
            resolve(moduleSource);
          }).catch(error => {
            reject(error);
          });

          break;
        default:
          resolve(null);
          break;
      }
    });
  }

  #addModule(moduleElement, moduleName) {
    if (Object.keys(this.#loadedModules).includes(moduleName)) {
      new this.#loadedModules[moduleName].default(moduleElement);
      module.init();
      moduleElement.setAttribute('data-module-loaded', '');

      return;
    }

    this.#loadModuleSource(moduleName).then(moduleSource => {
      if (moduleSource === null) {
        return;
      }

      const module = new moduleSource.default(moduleElement);
      module.init();
      moduleElement.setAttribute('data-module-loaded', '');
    }, error => console.log(error));
  }

  #loadModuleOnScroll() {
    const moduleElements = document.querySelectorAll('[data-module]:not([data-module-loaded])');

    if (moduleElements.length > 0) {
      moduleElements.forEach(moduleElement => {
        if (moduleElement.getBoundingClientRect().top < window.innerHeight * 1.25) {
          this.#addModule(moduleElement, moduleElement.dataset.module);
        }
      });
    } else {
      window.removeEventListener('scroll', this.#loadModuleOnScrollFunc);
    }
  }

  loadModules() {
    const moduleElements = document.querySelectorAll('[data-module]:not([data-module-loaded])');

    if (moduleElements.length > 0) {
      moduleElements.forEach(moduleElement => {
        if (typeof moduleElement.dataset.moduleDynamic !== 'undefined') {
          if (moduleElement.getBoundingClientRect().top < window.innerHeight * 1.25) {
            this.#addModule(moduleElement, moduleElement.dataset.module);
          } else {
            window.addEventListener('scroll', this.#loadModuleOnScrollFunc);
          }
        } else {
          this.#addModule(moduleElement, moduleElement.dataset.module);
        }
      });
    }
  }
}

export default ModulesLoader;

The above-mentioned class is based on the fact that JS modules in HTML are integrated with a data attribute on the respective element where they are needed. In the case of the FadeIn module, the HTML element that has the data attribute is then passed to the constructor of the class when a new instance is created. This provides a reference within the FadeIn class to the element that is to be faded in. Here is an example of an HTML element that is to be faded in – and is provided with the data attribute for loading the module:

<div data-module="fade-in" data-module-dynamic>
  <h1>This is a headline</h1>
  <p>The heading and this text fade in when scrolling. The script for this is integrated dynamically and asynchronously.</p>
</div>

As you can see above, data-module is the attribute that specifies which JS module we need. The attribute data-module-dynamic then ensures that this is not only loaded asynchronously, but also dynamically during scrolling – as soon as the referenced HTML element is just before the viewport.

So much for the integration in HTML, let’s now turn our attention to the ModulesLoader class in JS, which controls the integration of the JS modules. This is broken down below and explained piece by piece.

Variables and constructor

The class needs two variables that it can use across all functions. One of these variables is taken up again directly in the constructor.

  #loadedModules = {};
  #loadModuleOnScrollFunc;

  constructor() {
    this.#loadModuleOnScrollFunc = this.#loadModuleOnScroll.bind(this);
  }

The variable #loadedModules will be discussed in more detail below. The above code shows that the #loadModuleOnScrollFunc variable is assigned the #loadModuleOnScroll function with a binding to the class (ModulesLoader). This function is added to the scroll event of the window as a handler below. The above-mentioned assignment of the function to a variable is necessary so that the EventListener of the window object can be cleanly removed again later within the function itself – because as soon as all modules have been loaded, the EventListener is no longer required.

Initial call

  loadModules() {
    const moduleElements = document.querySelectorAll('[data-module]:not([data-module-loaded])');

    if (moduleElements.length > 0) {
      moduleElements.forEach(moduleElement => {
        if (typeof moduleElement.dataset.moduleDynamic !== 'undefined') {
          if (moduleElement.getBoundingClientRect().top < window.innerHeight * 1.25) {
            this.#addModule(moduleElement, moduleElement.dataset.module);
          } else {
            window.addEventListener('scroll', this.#loadModuleOnScrollFunc);
          }
        } else {
          this.#addModule(moduleElement, moduleElement.dataset.module);
        }
      });
    }
  }

The above-mentioned function loadModules is the entry point into the dynamic & asynchronous loading of JS modules. This function is called from outside – in the JS file in which the ModulesLoader class is integrated – to start loading the JS modules.

First, all HTML elements that have a data-module attribute but no data-module-loaded attribute are retrieved. The latter is later set on the HTML elements so that it is clear that the respective module has already been loaded.

The script is only processed further if corresponding HTML elements are found. For each element found, the next step checks whether the JS module should be loaded directly or dynamically when the element is scrolled into the viewport. If it is to be loaded directly, the #addModule function is called directly. Otherwise, the system checks whether the element is already in the viewport and then calls #addModule or whether it is not yet in the viewport. If it is not yet in the viewport, an EventListener is set to the scroll event of the window object.

Load modules while scrolling

  #loadModuleOnScroll() {
    const moduleElements = document.querySelectorAll('[data-module]:not([data-module-loaded])');

    if (moduleElements.length > 0) {
      moduleElements.forEach(moduleElement => {
        if (moduleElement.getBoundingClientRect().top < window.innerHeight * 1.25) {
          this.#addModule(moduleElement, moduleElement.dataset.module);
        }
      });
    } else {
      window.removeEventListener('scroll', this.#loadModuleOnScrollFunc);
    }
  }

If the module should only be loaded when the associated HTML element is in the viewport, an EventListener is set to the scroll event as described above. This triggers the above-mentioned #loadModuleOnScroll function when scrolling. The function checks whether the element is now in the viewport – or just before the viewport – or not. If it is, the #addModule function is called, which triggers the loading of the module. The EventListener is also removed again, as it is no longer required and would otherwise only consume unnecessary browser resources.

Load modules

  #addModule(moduleElement, moduleName) {
    if (Object.keys(this.#loadedModules).includes(moduleName)) {
      new this.#loadedModules[moduleName].default(moduleElement);
      module.init();
      moduleElement.setAttribute('data-module-loaded', '');

      return;
    }

    this.#loadModuleSource(moduleName).then(moduleSource => {
      if (moduleSource === null) {
        return;
      }

      const module = new moduleSource.default(moduleElement);
      module.init();
      moduleElement.setAttribute('data-module-loaded', '');
    }, error => console.log(error));
  }

As already written, the #addModule function is triggered both for modules to be loaded directly and for modules to be loaded dynamically in order to initiate the loading of the JS module.

The function first checks whether the module name already exists as a key in the #loadedModules object. #loadedModules contains all JS modules whose code has already been loaded. The module name represents the key. The value is the source of the module. In this way, if the module is placed multiple times in the DOM, the module can be reinitialized with the corresponding HTML element. This creates a new reference between the JS module and the associated HTML element. In the example in this article, the JS module therefore knows which HTML element it should fade in.

In this way, the script with the required JS module does not have to be loaded multiple times from the server. This saves network resources and therefore has a positive effect on performance.

If the module is not already present in the #loadedModules object, the loading of the script containing the module is triggered. This means that in the next step, the module source is requested once from the server. As can be seen in the code above, this is done asynchronously. As soon as the script has been loaded, the class of the loaded module is initialized and the attribute data-module-loaded is added to the HTML element.

In the code above, you can see that the script is loaded from the server using the #loadModuleSource function. This is shown below and described further below that:

  #loadModuleSource(moduleName) {
    return new Promise((resolve, reject) => {
      switch (moduleName) {
        case 'fade-in':
          import('./../modules/fadeIn.js').then(moduleSource => {
            this.#loadedModules = { ...this.#loadedModules, moduleName: moduleSource };
            resolve(moduleSource);
          }).catch(error => {
            reject(error);
          });

          break;
        default:
          resolve(null);
          break;
      }
    });
  }

A promise is returned in the #loadModulesource function. Within the promise, a switch is used to check which module must be loaded. In the above-mentioned example, there is currently only the fade-in module. The more complex the website and its modularity is, the more modules can be placed here.

Note: A dynamic in the form of not using a switch (or optionally if statements) and simply inserting the name (moduleName) of the module into the path of the import via string addition does not work. For this article, everything was compiled with Webpack. This means that the reference to the JS files to be loaded is made during compilation and not at runtime. A complete path must therefore already exist when compiling. The module name is only available at runtime, so unfortunately no dynamic can be created here.

If the module name exists as a case, the import function (standard JS function) is used to trigger the loading of the script from the server. The function loads the file asynchronously so that no other browser processes are initially blocked. As soon as the file has been successfully loaded, the source is added to the #loadedModules object (see explanation above) and the source is returned as the result of the Promise. The module can then be initialized in the #addModule function.

Conclusion

There are certainly thousands of ways to optimize pagespeed and save JS resources or at least reduce and defer them to such an extent that it has a positive effect on performance. The above is one option that we have often used in practice. So far, this has always proved successful. It gives us the opportunity to postpone the loading of JS to when it is needed. However, this does not mean that we have to do it without modularity.

This shows only a very small part of what we do on a daily basis in a wide variety of websites. If you want a high-performance website, simply contact us. We will be happy to advise you on your website project in a free and non-binding initial consultation.

Overview of Web development