Ein Ansatz zum Lazy Loading von Custom Elements

Avatar of Frederik Dohr
Frederik Dohr am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Wir sind hier Fans von Custom Elements. Ihr Design macht sie besonders empfänglich für Lazy Loading, was ein Segen für die Performance sein kann.

Inspiriert von den Experimenten eines Kollegen habe ich kürzlich damit begonnen, einen einfachen automatischen Lader zu schreiben: Immer wenn ein benutzerdefiniertes Element im DOM erscheint, wollen wir die entsprechende Implementierung laden, falls sie noch nicht verfügbar ist. Der Browser kümmert sich dann von da an um das Upgrade solcher Elemente.

Die Chancen stehen gut, dass Sie all dies nicht wirklich brauchen werden; es gibt normalerweise einen einfacheren Ansatz. Bewusst eingesetzt, können die hier gezeigten Techniken dennoch eine nützliche Ergänzung Ihres Werkzeugkastens sein.

Aus Konsistenzgründen soll unser Autoloader selbst ein Custom Element sein – was auch bedeutet, dass wir ihn einfach über HTML konfigurieren können. Aber zuerst identifizieren wir diese unaufgelösten benutzerdefinierten Elemente Schritt für Schritt.

class AutoLoader extends HTMLElement {
  connectedCallback() {
    let scope = this.parentNode;
    this.discover(scope);
  }
}
customElements.define("ce-autoloader", AutoLoader);

Angenommen, wir haben dieses Modul im Voraus geladen (die Verwendung von async ist ideal), können wir ein <ce-autoloader> Element in das <body> unseres Dokuments einfügen. Dies startet sofort den Entdeckungsprozess für alle Kindelemente von <body>, das nun unser Wurzelelement darstellt. Wir könnten die Suche auf einen Teilbaum unseres Dokuments beschränken, indem wir <ce-autoloader> stattdessen dem jeweiligen Container-Element hinzufügen – tatsächlich könnten wir sogar mehrere Instanzen für verschiedene Teilbäume haben.

Natürlich müssen wir immer noch diese discover Methode implementieren (als Teil der oben genannten AutoLoader Klasse).

discover(scope) {
  let candidates = [scope, ...scope.querySelectorAll("*")];
  for(let el of candidates) {
    let tag = el.localName;
    if(tag.includes("-") && !customElements.get(tag)) {
      this.load(tag);
    }
  }
}

Hier überprüfen wir unser Wurzelelement zusammen mit jedem einzelnen Nachfahren (*). Wenn es sich um ein benutzerdefiniertes Element handelt – wie durch Bindestrich-Tags angezeigt –, das aber noch nicht aktualisiert wurde, werden wir versuchen, die entsprechende Definition zu laden. Das Abfragen des DOM auf diese Weise kann kostspielig sein, daher sollten wir vorsichtig sein. Wir können die Belastung des Hauptthreads verringern, indem wir diese Arbeit verzögern.

connectedCallback() {
  let scope = this.parentNode;
  requestIdleCallback(() => {
    this.discover(scope);
  });
}

requestIdleCallback wird noch nicht universell unterstützt, aber wir können requestAnimationFrame als Fallback verwenden.

let defer = window.requestIdleCallback || requestAnimationFrame;

class AutoLoader extends HTMLElement {
  connectedCallback() {
    let scope = this.parentNode;
    defer(() => {
      this.discover(scope);
    });
  }
  // ...
}

Nun können wir mit der Implementierung der fehlenden load Methode fortfahren, um dynamisch ein <script> Element einzufügen.

load(tag) {
  let el = document.createElement("script");
  let res = new Promise((resolve, reject) => {
    el.addEventListener("load", ev => {
      resolve(null);
    });
    el.addEventListener("error", ev => {
      reject(new Error("failed to locate custom-element definition"));
    });
  });
  el.src = this.elementURL(tag);
  document.head.appendChild(el);
  return res;
}

elementURL(tag) {
  return `${this.rootDir}/${tag}.js`;
}

Beachten Sie die hartkodierte Konvention in elementURL. Die URL des src-Attributs geht davon aus, dass es ein Verzeichnis gibt, in dem alle Definitionen benutzerdefinierter Elemente gespeichert sind (z. B. <my-widget>/components/my-widget.js). Wir könnten ausgefeiltere Strategien entwickeln, aber das ist für unsere Zwecke gut genug. Die Auslagerung dieser URL in eine separate Methode ermöglicht die projektspezifische Unterklassebildung, wenn nötig.

class FancyLoader extends AutoLoader {
  elementURL(tag) {
    // fancy logic
  }
}

Auf jeden Fall stellen wir fest, dass wir uns auf this.rootDir verlassen. Hier kommt die erwähnte Konfigurierbarkeit ins Spiel. Fügen wir einen entsprechenden Getter hinzu.

get rootDir() {
  let uri = this.getAttribute("root-dir");
  if(!uri) {
    throw new Error("cannot auto-load custom elements: missing `root-dir`");
  }
  if(uri.endsWith("/")) { // remove trailing slash
    return uri.substring(0, uri.length - 1);
  }
  return uri;
}

Sie denken jetzt vielleicht an observedAttributes, aber das macht die Sache nicht einfacher. Außerdem scheint die Aktualisierung von root-dir zur Laufzeit etwas zu sein, das wir nie brauchen werden.

Nun können – und müssen – wir unser Komponentenverzeichnis konfigurieren: <ce-autoloader root-dir="/components">.

Damit kann unser Autoloader seine Arbeit tun. Außer, dass er nur einmal funktioniert, für Elemente, die bereits existieren, wenn der Autoloader initialisiert wird. Wir werden wahrscheinlich auch dynamisch hinzugefügte Elemente berücksichtigen wollen. Hier kommt MutationObserver ins Spiel.

connectedCallback() {
  let scope = this.parentNode;
  defer(() => {
    this.discover(scope);
  });
  let observer = this._observer = new MutationObserver(mutations => {
    for(let { addedNodes } of mutations) {
      for(let node of addedNodes) {
        defer(() => {
          this.discover(node);
        });
      }
    }
  });
  observer.observe(scope, { subtree: true, childList: true });
}

disconnectedCallback() {
  this._observer.disconnect();
}

Auf diese Weise benachrichtigt uns der Browser jedes Mal, wenn ein neues Element im DOM erscheint – oder besser gesagt, in unserem jeweiligen Teilbaum –, und wir nutzen dies, um den Entdeckungsprozess neu zu starten. (Sie könnten argumentieren, dass wir hier benutzerdefinierte Elemente neu erfinden, und Sie hätten irgendwie Recht.)

Unser Autoloader ist jetzt voll funktionsfähig. Zukünftige Verbesserungen könnten sich mit möglichen Race Conditions befassen und Optimierungen untersuchen. Aber die Chancen stehen gut, dass dies für die meisten Szenarien ausreichend ist. Lassen Sie mich in den Kommentaren wissen, wenn Sie einen anderen Ansatz haben, und wir können Notizen vergleichen!