JS asynchron und dynamisch einbinden

Wie binde ich JS asynchron ein, wenn es benötigt wird?

Um sich in Suchergebnissen besser zu positionieren ist, neben dem Content, die Geschwindigkeit der Website ein wichtiger Aspekt. Jeder, der bereits in Sachen Pagespeed-Optimierung tätig war, weiß, dass dies oft ein mühsames Unterfangen ist. Websites sind heutzutage mit unzähligen Funktionalitäten versehen, die dem User die Verwendung angenehmer machen und ihm ein „Aha“-Erlebnis bieten sollen. Damit einher geht aber auch, dass JavaScript (JS) ein wichtiger Bestandteil von Websites ist. Doch: Je mehr JS in einer Seite eingebunden ist, desto schlechter wird meistens die Geschwindigkeit der Seite. Das ist für den User nicht immer spürbar, aber umso spürbare in Pagespeed-Bewertungen von Google.

Es gibt unzählige Möglichkeiten, um die Geschwindigkeit einer Website zu optimieren. Alle diese hier zu behandeln, würde den Rahmen dieses Artikels sprengen. Einen guten Effekt kann man jedoch mit dem asynchronen Einbinden von JS erzielen. Nachfolgend ist eine Möglichkeit beschrieben, wie man JS Module asynchron einbinden kann – und zwar nur dann, wenn sie auch gebraucht werden.

Wann bietet es ich an JS asynchron einzubinden?

Es bietet sich immer dann an JS asynchron einzubinden, wenn man es nicht direkt beim Laden der Seite benötigt. Wenn das entsprechende JS File also Funktionen beinhaltet, die nicht direkt beim Laden bzw. bei der Anzeige des above the fold Contents (Content, der beim initialen Laden innerhalb des Viewports dargestellt wird und für den User direkt sichtbar ist) verwendet werden. Aber auch JS mit Funktionen, die im above the fold Content benötigt werden, können dann asynchron eingebunden werden, wenn das asynchrone Einbinden sich nicht negativ für den User oder andere SEO-relevante Kennzahlen (z.B. Layout Shifts) auswirkt. Eine allgemeine Regel gibt es hier nicht, da jede Website unterschiedlich ist und auch die Relevanz der verschiedenen, auf der jeweiligen Seite verwendeten JS Funktionen komplett unterschiedlich ist.

Exkurs: JS Modulen

JS Module sind, im Kontext dieses Artikels, Funktionalitäten, die in separate JS Files ausgelagert sind und die immer wieder, an den unterschiedlichsten Stellen verwendet werden können. Nachfolgender Beispiel-Code stellt ein Modul (in Form einer Klasse) dar, der HTML-Elementen eine Klasse hinzufügt, sobald diese in den Viewport gescrollt werden:

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;

Wie man sieht, besitzt das JS File einen Export. D.h. man kann die Datei, bzw. die darin deklarierte Klasse, in anderen JS Files importieren und wiederverwenden. Hier ein Beispiel für einen Import:

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

Jeder, der mit npm Packages arbeitet, hat solche Imports schon gesehen und verwendet. Oftmals bleibt es auch bei dieser Art des Imports von Modulen. Und ein Compiler (Webpack o.ä.) bundled dann den importierten Code in ein oder zwei JS Files, die dann direkt im Head oder am Ende des Body-Tags der Website eingebunden sind (ggf. auch asynchron – oft aber synchron). Im folgenden Abschnitt wird anhand des o.g. Beispiels eine Möglichkeit beschrieben, wie man ein solches Modul performanceoptimiert einbinden kann.

Dynamisches, asynchrones Einbinden von JS Modulen

O.g. Beispiel-Modul (FadeIn Klasse) soll nun asynchron eingebunden werden und immer nur dann, wenn es benötigt wird. Für diejenigen, die schnell etwas Code brauchen und keine tiefere Erklärung, gibt es hier bereits die komplette Klasse zum asynchronen, dynamischen Laden von JS Modulen (mit Beispiel des Ladens der FadeIn Klasse). Für alle anderen, wird der Code nachfolgend in seine Bestandteile zerlegt und genauer erklärt.

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;

Die o.g. Klasse basiert darauf, dass JS Module im HTML mit einem data-Attribut auf dem jeweiligen Element eingebunden werden, wo sie gebraucht werden. Im Falle des FadeIn Modules wird dann dem Constructor der Klasse, beim Erstellen einer neuen Instanz, das HTML Element übergeben, welches das data-Attribut besitzt. Somit hat man dann innerhalb der FadeIn Klasse eine Referenz auf das Element, das eingefaded werden soll. Hier ein Beispiel für ein HTML Element, das eingefadet werden soll – und mit dem data-Attribut zum Laden des Moduls versehen ist:

<div data-module="fade-in" data-module-dynamic>
  <h1>Dies ist eine Überschrift</h1>
  <p>Die Überschrift und dieser Text faden beim Scrollen ein. Das Script dafür wird dynamisch und asynchron eingebunden.</p>
</div>

Wie man oben erkennt, ist data-module das Attribut, das angibt, welches JS Modul wir benötigen. Das Attribut data-module-dynamic sorgt dann dafür, dass dieses nicht nur asynchron geladen wird, sondern auch dynamisch beim Scroll – sobald das referenzierte HTML Element kurz vor dem Viewport angelangt ist.

So viel zur Einbindung im HTML, widmen wir uns nun der ModulesLoader Klasse im JS, die das Einbinden der JS Module regelt. Diese wird im Folgenden zerlegt und Stück für Stück erklärt.

Variablen und Constructor

Die Klasse braucht zwei Variablen, die sie über alle Funktionen hinweg verwenden kann. Eine dieser Variablen wird direkt im Constructor wieder aufgegriffen.

  #loadedModules = {};
  #loadModuleOnScrollFunc;

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

Auf die Variable #loadedModules wird weiter unten noch genauer eingegangen. O.g. Code zeigt, dass der Variable #loadModuleOnScrollFunc die Funktion #loadModuleOnScroll mit einem Binding auf die Klasse (ModulesLoader) zugewiesen wird. Besagte Funktion wird weiter unten dem Scroll-Event des Windows als Handler hinzugefügt. Das o.g. Zuweisen der Funktion zu einer Variable ist notwendig, damit man später, innerhalb der Funktion selbst, den EventListener auf das window-Objekt sauber wieder entfernen kann – denn sobald alle Module geladen wurden, wird der EventListener nicht mehr benötigt.

Initialer Aufruf

  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);
        }
      });
    }
  }

Die o.g. Funktion loadModules ist der Einstiegspunkt in das dynamische & asynchrone Laden von JS Modulen. Diese Funktion wird von außerhalb – in dem JS File, in dem die ModulesLoader Klasse eingebunden ist – aufgerufen, um das Laden der JS Module zu starten.

Zunächst werden alle HTML Elemente geholt, die ein data-module Attribut, aber kein data-module-loaded Attribut besitzen. Letzteres wird später auf die HTML Elemente gesetzt, damit deutlich wird, dass das jeweilige Modul bereits geladen wurde.

Nur, wenn entsprechende HTML Elemente gefunden werden, wird das Script weiter abgearbeitet. Bei jedem gefunden Element wird im nächsten Schritt geprüft, ob das JS Modul direkt oder dynamisch, wenn das Element in den Viewport gescrollt wird, geladen werden soll. Soll es direkt geladen werden, wird die Funktion #addModule direkt aufgerufen. Andernfalls wird geprüft, ob das Element bereits im Viewport ist, und dann #addModule aufgerufen oder, ob es noch nicht im Viewport ist. Ist es noch nicht im Viewport, wird ein EventListener auf das Scroll-Event vom window-Objekt gesetzt.

Module beim Scrollen laden

  #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);
    }
  }

Wenn das Modul erst geladen werden soll, wenn das zugehörige HTML Element im Viewport ist, wird, wie o.g., ein EventListener auf das Scroll-Event gesetzt. Dieser triggert beim Scrollen die o.g. Funktion #loadModuleOnScroll. Innerhalb der Funktion wird geprüft, ob das Element nun im Viewport – bzw. kurz vor dem Viewport – ist oder nicht. Wenn ja, wird die Funktion #addModule aufgerufen, die das Laden des Moduls anstößt. Außerdem wird der EventListener wieder entfernt, da dieser nun nicht mehr benötigt wird und ansonsten nur unnötige Ressourcen des Browsers verbrauchen würde.

Module Laden

  #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));
  }

Wie bereits geschrieben, wird sowohl bei direkt zu ladenden Modulen wie auch bei dynamisch zu ladenden Modulen die Funktion #addModule angestoßen, um das Laden des JS Moduls einzuleiten.

Innerhalb der Funktion wird zuerst geprüft, ob der Modulname bereits als Key im Objekt #loadedModules vorhanden ist. #loadedModules enthält alle JS Module, deren Code bereits geladen wurde. Dabei stellt der Modulname den Key dar. Der Value ist jeweils der Source des Moduls. Auf diese Art kann – wenn das Modul mehrfach im DOM platziert ist – das Module mit dem jeweils zugehörigen HTML Element neu initialisiert werden. Somit wird eine neue Referenz zwischen JS Modul und dem zugehörigen HTML Element geschaffen. Im Beispiel dieses Artikels weiß das JS Modul also, welches HTML Element es einfaden soll.

Auf diese Art muss das Script mit dem benötigten JS Modul nicht mehrfach vom Server geladen werden. Das spart Netzwerkressourcen und wirkt sich damit positiv auf die Performance aus.

Insofern das Module nicht bereits im Objekt #loadedModules vorhanden ist, wird das Laden des Scripts, das das Modul beinhaltet, angestoßen. D.h. im nächsten Schritt wird der Modul Source einmalig beim Server angefragt. Wie in o.g. Code zu erkennen ist, geschieht das asynchron. Sobald das Script geladen wurde, wird die Klasse des geladenen Modules initialisiert und dem HTML Element wird das Attribut data-module-loaded hinzugefügt.

In o.g. Code sieht man, dass das Laden des Scripts vom Server über die Funktion #loadModuleSource erfolgt. Diese ist nachfolgend dargestellt und darunter weiter beschrieben:

  #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;
      }
    });
  }

In der Funktion #loadModulesource wird eine Promise returned. Innerhalb der Promise wird mit einem Switch zunächst geprüft, welches Modul überhaupt geladen werden soll. In o.g. Beispiel gibt es aktuell nur das fade-in Modul. Je komplexer die Website und deren Modularität ist, desto mehr Module können hier verbaut werden.

Hinweis: Eine Dynamic in der Form, dass man auf einen Switch (oder wahlweise auch if-Statements) verzichtet und den Namen (moduleName) des Moduls einfach per String-Addition in den Pfad des Imports einfügt, funktioniert hier nicht. Für diesen Artikel wurde am Ende alles mit Webpack kompiliert. D.h. die Referenz zu den zu ladenden JS Files erfolgt beim Kompilieren und nicht zur Laufzeit. Es muss also beim Kompilieren bereits ein vollständiger Pfad existieren. Der Modulname liegt aber erst zur Laufzeit vor, damit kann hier leider keine Dynamic geschaffen werden.

Wenn der Modulname als Case existiert, wird über die Funktion import (Standardfunktion vom JS) das Laden des Scripts vom Server angestoßen. Die Funktion lädt die Datei dabei asynchron, sodass zunächst keine weiteren Browserprozesse dadurch blockiert werden. Sobald die Datei dann erfolgreich geladen wurde, wird der Source dem #loadedModules Objekt (siehe auch Erklärung weiter oben) hinzugefügt und der Source mittels resolve als Ergebnis der Promise zurückgegeben. Damit kann das Module dann in der #addModule Funktion initialisiert werden.

Fazit

Sicherlich gibt es tausende von Möglichkeiten die Pagespeed zu optimieren und JS Ressourcen zu sparen oder zumindest so weit zu reduzieren und aufzuschieben, dass es sich positiv auf die Performance auswirkt. O.g. Weg ist eine Möglichkeit, die wir in der Praxis oftmals bereits angewendet haben. Bislang hat sich dies immer bewehrt. Wir haben so die Möglichkeit das Laden von JS auf den Zeitpunkt zu verlagern, dem es gebraucht wird. Dabei müssen wir aber keineswegs auf eine Modularität verzichten.

Das zeigt nur einen sehr kleinen Teil von dem, was wir täglich in den verschiedensten Websites machen. Wenn Sie eine performante Website möchten, kontaktieren Sie uns einfach. Wir beraten Sie gerne in einem kostenlosen und unverbindlichen Erstgespräch, zu Ihrem Website-Vorhaben.

Übersicht von Webentwicklung