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!
Warum nicht
:not(:defined)verwenden, um nach nicht geladenen Elementen zu suchen.Ich würde auch eine andere Art von Autoloader verwenden, die leichter mit WebPack kompatibel ist. So etwas wie
const elems = {'my-elem': () => import('./my-elem.js')
};
// …
customElements.define(elem, await elemselem. default);`
Oh, ich hatte nicht an
:definedin diesem Zusammenhang gedacht; das ist eine ausgezeichnete Idee und viel eleganter, danke!Ich stimme zu, dass ein Ansatz wie dieses
elemsObjekt in manchen Szenarien vorteilhaft sein kann (daher der vorsichtige Hinweis in der Einleitung). In meinem Fall wollte ich mich bewusst nicht auf Bundler oder ähnliche Tools verlassen.Ich träume immer noch von HTML Imports. Stellen Sie sich vor, beliebige Landing-Bereiche in separaten HTML-Dateien. Wichtig für mich ist, dass sie im Sources-Tab zusammen mit anderen angezeigt werden. Fetch ist also nicht hilfreich. Also habe ich ein dediziertes Custom Element mit iframes ausprobiert, die separate HTML-Dateien darin laden, und dieses Element greift deren Dokumenteninhalt ab und verschiebt ihn in unser Hauptdokument. Das löst mein Problem mit der Anzeige im Sources-Tab. Aber Race Conditions machen mich fertig… Es funktioniert etwa 15 von 16 Mal.
Ich wäre interessiert daran, ein Beispiel für Ihren iframes-Ansatz zu sehen, Michael; möchten Sie es in einem CodePen oder Gist veröffentlichen? (Letzteres würde eigene Diskussionen ermöglichen.)
Ich sympathisiere mit dem HTML-Import-Gedenken, obwohl ich hin und her überlege, was es hätte oder hätte sein können.
Das ist ein wirklich guter Ausgangspunkt für die Idee; großartig, um etwas Wissen zu demonstrieren. Ich habe mich schon einmal damit beschäftigt, aber hier ist die Zusammenfassung der Verbesserungen.
Anstatt einen Mutation Observer zu verwenden, der bei ALLEN hinzugefügten und entfernten Knoten ausgelöst wird, verwenden wir einen CSS-Animation-Listener, um
:not(:defined)Elemente zu finden.Da eine Web-Komponente innerhalb einer Shadow-Root verwendet werden könnte, reicht es nicht aus,
this.parentNodeeinzustellen, um sie zu erfassen. Fügen Sie stattdessen jedem eingehenden Shadow-Root einen Listener für die CSS-Animation hinzu, um weitere Elemente zu identifizieren.Die Bestimmung, wo die Definitionen geladen werden sollen, erfolgt durch Bezugnahme auf den Speicherort des anfänglichen Skripts (
document.currentScript.src) und Traversierung von dort aus. Dies unterstützt die Möglichkeit, dies über ein CDN (meine bevorzugte Methode zum Hosten von Definitionen) zu verwenden.Da die Anfrage für die Definition asynchron erfolgen kann, ist es möglich, dass wir zweimal anfragen, wenn ein Element erscheint. Wenn das Element gefunden wird, wird es in eine Registrierung aufgenommen, damit es nicht erneut angefragt wird. Eine Verbesserung, die ich nicht berücksichtigt habe, wäre, fehlgeschlagene Anfragen erneut zu versuchen.
Habe gerade Ihren Artikel heute gelesen, da ich auch damit experimentiert habe. Tolles Lesen! Danke für all die guten Infos!
Das ist wirklich nett! Ich denke, die Verwendung von
<script type="module">könnte hier auch gut funktionieren.Anstatt ein
<script>-Tag zu erstellen, um die Implementierung dieser Komponente manuell zu laden, könnten Sie stattdessen die dynamischeimport()-Funktion aufrufen.Wenn ich es mir recht überlege, müssen Sie nicht unbedingt zu
<script type="module">wechseln, Sie müssten nur sicherstellen, dass Ihre Komponenten im"use strict"Modus laufen, da dies eine Voraussetzung ist, um im 'module'-Modus mit derimport()-Anweisung zu laufen.Hier wäre die aktualisierte Version der
AutoLoader.load()-Methode mit derimport()-basierten Syntax (ich habe mich entschieden, sie in TS zu machen, um die Dinge in meiner Demo zu erklären).Dies führt im Wesentlichen nur das andere Skript als Nebeneffekt aus, Sie erhalten keine Referenzen aus dem
exportdes Skripts. Es dient nur dazu, den Code zu laden, der die Custom Element-Definitionen ausführt.Guter Punkt, Brandon: Ich erinnere mich ehrlich gesagt nicht mehr, warum ich mich entschieden habe, ein
<script>-Element einzufügen, anstattimport()zu verwenden. Wenn ESM / Strict Mode eine akzeptable Einschränkung ist (was für Custom Elements wahrscheinlich der Fall ist), scheint Ihr Vorschlag eine schöne Vereinfachung zu sein. Danke, dass Sie darauf hingewiesen haben!