Im letzten Artikel haben wir uns mit Web Components beschäftigt und eine HTML-Vorlage erstellt, die sich zwar im Dokument befindet, aber erst gerendert wird, wenn wir sie benötigen.
Als Nächstes werden wir unsere Suche fortsetzen, um eine eigene Elementversion der unten stehenden Dialogkomponente zu erstellen, die derzeit nur HTMLTemplateElement verwendet.
Drücken wir also weiter, indem wir ein eigenes Element erstellen, das unser template#dialog-template-Element in Echtzeit verbraucht.
Artikelserie
- Eine Einführung in Web Components
- Erstellung wiederverwendbarer HTML-Vorlagen
- Ein eigenes Element von Grund auf erstellen (Dieser Beitrag)
- Kapselung von Stil und Struktur mit Shadow DOM
- Fortgeschrittene Werkzeuge für Web Components
Ein eigenes Element erstellen
Das A und O von Web Components sind eigene Elemente. Die customElements API gibt uns einen Weg, eigene HTML-Tags zu definieren, die in jedem Dokument verwendet werden können, das die definierende Klasse enthält.
Stellen Sie es sich wie eine React- oder Angular-Komponente vor (z. B. ), aber ohne die React- oder Angular-Abhängigkeit. Native eigene Elemente sehen so aus: . Wichtiger noch, stellen Sie es sich als Standardelement vor, das in Ihren React-, Angular-, Vue-, [fügen Sie hier Ihr bevorzugtes Framework ein]-Anwendungen ohne viel Aufwand verwendet werden kann.
Im Wesentlichen besteht ein eigenes Element aus zwei Teilen: einem Tag-Namen und einer Klasse, die von der integrierten HTMLElement-Klasse erbt. Die einfachste Version unseres eigenen Elements würde so aussehen:
class OneDialog extends HTMLElement {
connectedCallback() {
this.innerHTML = `<h1>Hello, World!</h1>`;
}
}
customElements.define('one-dialog', OneDialog);
Innerhalb eines eigenen Elements ist der Wert von this eine Referenz auf die Instanz des eigenen Elements.
Im obigen Beispiel haben wir ein neues, standardkonformes HTML-Element definiert: <one-dialog></one-dialog>. Es tut noch nicht viel. Vorerst erstellt die Verwendung des <one-dialog>-Tags in einem beliebigen HTML-Dokument ein neues Element mit einem <h1>-Tag, auf dem „Hello, World!“ steht.
Wir werden definitiv etwas Robusteres wollen, und wir haben Glück. Im letzten Artikel haben wir uns mit der Erstellung einer Vorlage für unseren Dialog befasst, und da wir Zugriff auf diese Vorlage haben werden, wollen wir sie in unserem eigenen Element nutzen. In diesem Beispiel haben wir ein Skript-Tag hinzugefügt, um einige Dialog-Magie zu vollbringen. Entfernen wir das vorerst, da wir unsere Logik von der HTML-Vorlage in die eigene Elementklasse verschieben werden.
class OneDialog extends HTMLElement {
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
}
Jetzt ist unser eigenes Element (<one-dialog>) definiert und der Browser angewiesen, den Inhalt der HTML-Vorlage dort zu rendern, wo das eigene Element aufgerufen wird.
Unser nächster Schritt ist, unsere Logik in unsere Komponentenklasse zu verschieben.
Lebenszyklusmethoden von eigenen Elementen
Wie React oder Angular haben eigene Elemente Lebenszyklusmethoden. Sie wurden bereits passiv mit connectedCallback vertraut gemacht, die aufgerufen wird, wenn unser Element dem DOM hinzugefügt wird.
connectedCallback ist getrennt vom constructor des Elements. Während der Konstruktor zum Einrichten der Grundgerüste des Elements verwendet wird, wird connectedCallback typischerweise zum Hinzufügen von Inhalten zum Element, zum Einrichten von Event-Listenern oder zur Initialisierung der Komponente verwendet.
Tatsächlich kann der Konstruktor aus Designgründen nicht dazu verwendet werden, Attribute des Elements zu ändern oder zu manipulieren. Wenn wir eine neue Instanz unseres Dialogs mit document.createElement erstellen würden, würde der Konstruktor aufgerufen. Ein Verbraucher des Elements würde einen einfachen Knoten ohne Attribute oder eingefügte Inhalte erwarten.
Die Funktion createElement bietet keine Optionen zur Konfiguration des zurückgegebenen Elements. Es liegt daher auf der Hand, dass der Konstruktor nicht die Fähigkeit haben sollte, das von ihm erstellte Element zu ändern. Das überlässt uns connectedCallback als Ort, an dem wir unser Element modifizieren können.
Bei Standard-integrierten Elementen spiegelt der Zustand des Elements typischerweise die vorhandenen Attribute und deren Werte wider. Für unser Beispiel werden wir uns genau ein Attribut ansehen: [open]. Um dies zu tun, müssen wir Änderungen an diesem Attribut beobachten, und dafür brauchen wir attributeChangedCallback. Diese zweite Lebenszyklusmethode wird aufgerufen, wenn einer der observedAttributes des Elementkonstruktors aktualisiert wird.
Das mag einschüchternd klingen, aber die Syntax ist ziemlich einfach.
class OneDialog extends HTMLElement {
static get observedAttributes() {
return ['open'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (newValue !== oldValue) {
this[attrName] = this.hasAttribute(attrName);
}
}
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
}
In unserem obigen Fall interessiert uns nur, ob das Attribut gesetzt ist oder nicht, wir kümmern uns nicht um einen Wert (dies ähnelt dem HTML5-Attribut required bei Eingaben). Wenn dieses Attribut aktualisiert wird, aktualisieren wir die open-Eigenschaft des Elements. Eine Eigenschaft existiert auf einem JavaScript-Objekt, während ein Attribut auf einem HTMLElement existiert. Diese Lebenszyklusmethode hilft uns, die beiden synchron zu halten.
Wir verpacken die Aktualisierung innerhalb von attributeChangedCallback in eine bedingte Prüfung, ob neuer und alter Wert gleich sind. Wir tun dies, um eine Endlosschleife in unserem Programm zu verhindern, da wir später einen Getter und Setter für die Eigenschaft erstellen werden, der die Eigenschaft und die Attribute synchron hält, indem er das Attribut des Elements setzt, wenn die Eigenschaft des Elements aktualisiert wird. attributeChangedCallback tut das Gegenteil: Es aktualisiert die Eigenschaft, wenn sich das Attribut ändert.
Jetzt kann ein Autor unsere Komponente nutzen, und die Anwesenheit des Attributs open bestimmt, ob der Dialog standardmäßig geöffnet ist oder nicht. Um dies etwas dynamischer zu gestalten, können wir benutzerdefinierte Getter und Setter für die open-Eigenschaft unseres Elements hinzufügen.
class OneDialog extends HTMLElement {
static get boundAttributes() {
return ['open'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
this[attrName] = this.hasAttribute(attrName);
}
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
get open() {
return this.hasAttribute('open');
}
set open(isOpen) {
if (isOpen) {
this.setAttribute('open', true);
} else {
this.removeAttribute('open');
}
}
}
Unser Getter und Setter halten den open-Attributwert (am HTML-Element) und den Eigenschaftswert (am DOM-Objekt) synchron. Das Hinzufügen des open-Attributs setzt element.open auf true und das Setzen von element.open auf true fügt das open-Attribut hinzu. Wir tun dies, um sicherzustellen, dass der Zustand unseres Elements durch seine Eigenschaften widergespiegelt wird. Dies ist technisch nicht erforderlich, wird aber als bewährte Methode für die Erstellung eigener Elemente angesehen.
Dies führt zwar unweigerlich zu etwas Boilerplate, aber die Erstellung einer abstrakten Klasse, die diese synchron hält, ist eine ziemlich triviale Aufgabe, indem man über die Liste der beobachteten Attribute iteriert und Object.defineProperty verwendet.
class AbstractClass extends HTMLElement {
constructor() {
super();
// Check to see if observedAttributes are defined and has length
if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
// Loop through the observed attributes
this.constructor.observedAttributes.forEach(attribute => {
// Dynamically define the property getter/setter
Object.defineProperty(this, attribute, {
get() { return this.getAttribute(attribute); },
set(attrValue) {
if (attrValue) {
this.setAttribute(attribute, attrValue);
} else {
this.removeAttribute(attribute);
}
}
}
});
}
}
}
// Instead of extending HTMLElement directly, we can now extend our AbstractClass
class SomeElement extends AbstractClass { /* Omitted */ }
customElements.define('some-element', SomeElement);
Das obige Beispiel ist nicht perfekt, es berücksichtigt nicht die Möglichkeit von Attributen wie open, denen kein Wert zugewiesen ist, sondern die nur auf der Anwesenheit des Attributs basieren. Eine perfekte Version davon zu erstellen, würde den Rahmen dieses Artikels sprengen.
Jetzt, da wir wissen, ob unser Dialog geöffnet ist oder nicht, fügen wir einige Logiken hinzu, um das Anzeigen und Verbergen tatsächlich zu bewerkstelligen.
class OneDialog extends HTMLElement {
/** Omitted */
constructor() {
super();
this.close = this.close.bind(this);
this._watchEscape = this._watchEscape.bind(this);
}
set open(isOpen) {
this.querySelector('.wrapper').classList.toggle('open', isOpen);
this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
if (isOpen) {
this._wasFocused = document.activeElement;
this.setAttribute('open', '');
document.addEventListener('keydown', this._watchEscape);
this.focus();
this.querySelector('button').focus();
} else {
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
this.removeAttribute('open');
document.removeEventListener('keydown', this._watchEscape);
this.close();
}
}
close() {
if (this.open !== false) {
this.open = false;
}
const closeEvent = new CustomEvent('dialog-closed');
this.dispatchEvent(closeEvent);
}
_watchEscape(event) {
if (event.key === 'Escape') {
this.close();
}
}
}
Hier passiert viel, aber lassen Sie es uns Schritt für Schritt durchgehen. Als erstes holen wir unseren Wrapper und schalten die Klasse .open basierend auf isOpen um. Um unser Element zugänglich zu halten, müssen wir auch das Attribut aria-hidden umschalten.
Wenn der Dialog geöffnet ist, speichern wir eine Referenz auf das zuvor fokussierte Element. Dies dient der Barrierefreiheit. Wir fügen auch einen Keydown-Listener zum Dokument hinzu, namens watchEscape, den wir im Konstruktor an die this-Referenz des Elements gebunden haben, ähnlich wie React Methodenaufrufe in Klassensystemen handhabt.
Wir tun dies nicht nur, um die richtige Bindung für this.close zu gewährleisten, sondern auch, weil Function.prototype.bind eine Instanz der Funktion mit dem gebundenen Aufruf-Kontext zurückgibt. Indem wir im Konstruktor eine Referenz auf die neu gebundene Methode speichern, können wir den Listener entfernen, wenn der Dialog getrennt wird (mehr dazu gleich). Wir schließen ab, indem wir uns auf unser Element konzentrieren und den Fokus auf das richtige Element in unserem Shadow-Root setzen.
Wir erstellen auch eine nette kleine Hilfsmethode zum Schließen unseres Dialogs, die ein benutzerdefiniertes Ereignis auslöst, das einen Listener informiert, dass der Dialog geschlossen wurde.
Wenn das Element geschlossen ist (d. h. !open), prüfen wir, ob die Eigenschaft this._wasFocused definiert ist und eine focus-Methode hat, und rufen diese auf, um den Fokus des Benutzers zurück in den normalen DOM zu lenken. Dann entfernen wir unseren Event-Listener, um Speicherlecks zu vermeiden.
Wo wir gerade beim Aufräumen sind, das bringt uns zu einer weiteren Lebenszyklusmethode: disconnectedCallback. disconnectedCallback ist das Gegenteil von connectedCallback, da die Methode aufgerufen wird, sobald das Element aus dem DOM entfernt wird, und uns erlaubt, alle Event-Listener oder MutationObservers, die an unser Element angehängt sind, zu bereinigen.
Es trifft sich, dass wir noch ein paar Event-Listener zu verdrahten haben.
class OneDialog extends HTMLElement {
/* Omitted */
connectedCallback() {
this.querySelector('button').addEventListener('click', this.close);
this.querySelector('.overlay').addEventListener('click', this.close);
}
disconnectedCallback() {
this.querySelector('button').removeEventListener('click', this.close);
this.querySelector('.overlay').removeEventListener('click', this.close);
}
}
Jetzt haben wir ein gut funktionierendes, weitgehend barrierefreies Dialogelement. Es gibt ein paar kleine Verfeinerungen, die wir vornehmen könnten, wie z. B. das Erfassen des Fokus auf dem Element, aber das liegt außerhalb des Rahmens dessen, was wir hier lernen wollen.
Es gibt noch eine weitere Lebenszyklusmethode, die für unser Element nicht relevant ist: adoptedCallback, die ausgelöst wird, wenn das Element in einen anderen Teil des DOMs übernommen wird.
Im folgenden Beispiel sehen Sie nun, dass unser Vorlagenelement von einem Standard-<one-dialog>-Element verbraucht wird.
Eine weitere Sache: Nicht-präsentationsbasierte Komponenten
Die bisher von uns erstellte <one-template> ist ein typisches eigenes Element, das Markup und Verhalten enthält, das in das Dokument eingefügt wird, wenn das Element eingeschlossen wird. Allerdings müssen nicht alle Elemente visuell gerendert werden. Im React-Ökosystem werden Komponenten oft verwendet, um Anwendungszustand oder andere wichtige Funktionen zu verwalten, wie z. B. <Provider /> in react-redux.
Stellen wir uns für einen Moment vor, unsere Komponente sei Teil einer Reihe von Dialogen in einem Workflow. Wenn ein Dialog geschlossen wird, soll der nächste geöffnet werden. Wir könnten eine Wrapper-Komponente erstellen, die auf unser dialog-closed-Ereignis hört und den Workflow durchläuft.
class DialogWorkflow extends HTMLElement {
connectedCallback() {
this._onDialogClosed = this._onDialogClosed.bind(this);
this.addEventListener('dialog-closed', this._onDialogClosed);
}
get dialogs() {
return Array.from(this.querySelectorAll('one-dialog'));
}
_onDialogClosed(event) {
const dialogClosed = event.target;
const nextIndex = this.dialogs.indexOf(dialogClosed);
if (nextIndex !== -1) {
this.dialogs[nextIndex].open = true;
}
}
}
Dieses Element hat keine präsentationsbasierte Logik, sondern dient als Controller für den Anwendungszustand. Mit etwas Aufwand könnten wir ein Redux-ähnliches Zustandsverwaltungssystem mit nichts weiter als einem eigenen Element erstellen, das den Zustand einer gesamten Anwendung auf die gleiche Weise verwalten könnte, wie es die React Redux Wrapper tun.
Das ist ein tieferer Einblick in eigene Elemente
Nun haben wir ein ziemlich gutes Verständnis von eigenen Elementen und unser Dialog beginnt Gestalt anzunehmen. Aber er hat immer noch einige Probleme.
Beachten Sie, dass wir etwas CSS hinzufügen mussten, um den Dialog-Button neu zu stylen, da die Stile unseres Elements mit dem Rest der Seite kollidieren. Während wir Namensstrategien (wie BEM) verwenden könnten, um sicherzustellen, dass unsere Stile keine Konflikte mit anderen Komponenten verursachen, gibt es einen freundlicheren Weg, Stile zu isolieren. Spoiler! Es ist Shadow DOM, und darum geht es im nächsten Teil dieser Serie über Web Components.
Eine weitere Sache, die wir tun müssen, ist, eine neue Vorlage für *jede* Komponente zu definieren oder einen Weg zu finden, Vorlagen für unseren Dialog zu wechseln. Wie die Dinge stehen, kann pro Seite nur ein Dialogtyp vorhanden sein, da die Vorlage, die er verwendet, immer vorhanden sein muss. Wir brauchen also entweder eine Möglichkeit, dynamische Inhalte einzuspeisen, oder eine Möglichkeit, Vorlagen zu tauschen.
Im nächsten Artikel werden wir uns mit Möglichkeiten beschäftigen, die Benutzerfreundlichkeit des von uns gerade erstellten <one-dialog>-Elements zu erhöhen, indem wir Stil- und Inhaltskapselung mit Shadow DOM einbeziehen.
Artikelserie
- Eine Einführung in Web Components
- Erstellung wiederverwendbarer HTML-Vorlagen
- Ein eigenes Element von Grund auf erstellen (Dieser Beitrag)
- Kapselung von Stil und Struktur mit Shadow DOM
- Fortgeschrittene Werkzeuge für Web Components
Könnten Sie diesen Satz bitte näher erläutern?
„*Dies führt zwar unweigerlich zu etwas Boilerplate, aber die Erstellung einer abstrakten Klasse, die diese synchron hält, ist eine ziemlich triviale Aufgabe, indem man über die Liste der beobachteten Attribute iteriert und
Object.definePropertyverwendet.*“?Ich habe nicht ganz verstanden, was Sie damit meinen.
Hallo Konstantin, ich habe einen Code-Schnipsel in den Artikel eingefügt, um kurz zu zeigen, wie man
object.definePropertyin einer abstrakten Klasse verwenden könnte, um Getter und Setter automatisch zu definieren.Ich freue mich auf den nächsten Artikel! Aber zuerst ein paar Fragen…
Könnte die Vorlage innerhalb des
one-dialog-Elements definiert werden, wodurch das externe Vorlagenelement entfällt?Wie fügen wir benutzerdefinierte CSS-Eigenschaften zu unseren Elementen hinzu und wie reagieren wir auf Änderungen dieser oder standardmäßiger Eigenschaften? Könnten wir zum Beispiel normales CSS auf dem
one-dialog-Element verwenden, um Breite und Höhe des Dialogs festzulegen, plus eine benutzerdefinierte CSS-Eigenschaft, um festzulegen, ob er modal ist oder nicht?Ist es möglich, von etwas anderem als der Basisklasse
HTMLElementzu erben? Ist es zum Beispiel möglich, von einemtextarea- odersection-Element zu erben? Wenn ja, wo finde ich eine gute Liste dessen, wovon wir erben können?Die Vorlage kann absolut innerhalb des
one-dialog-Elements enthalten sein. Wenn Sie diesen Weg einschlagen, ist es wahrscheinlich am besten, die Vorlage als Kind des Elements einzufügen, das verbraucht werden kann. Sie können auch einfachthis.innerHTMLverwenden, was wir im nächsten Artikel dieser Serie tun: Stil und Struktur mit Shadow DOM kapseln.Das Hinzufügen von Shadow DOM zu unserer Komponente löst eine Reihe Ihrer Bedenken, weshalb es als nächstes kommt. Sie stellen absolut die richtigen Fragen, und die gute Nachricht ist, dass Sie fast alles tun können, was Sie wollen. Wir gehen dort nicht viel darauf ein, aber Shadow DOM enthält
:host()und:host-context()Selektoren in CSS, um auf benutzerdefinierte Stile zu reagieren.Ich verstehe nicht ganz, wie eine Dialog-Workflow-Komponente mit
one-dialog-Komponenten integriert werden könnte, um die Darstellung von Dialogen zu steuern. Helfen Sie mir?Das ist eine großartige Artikelserie! Vielen Dank, Caleb!
Ist
boundAttributesfalsch, wenn esobservedAttributesheißt?