Webkomponenten statt zu reagieren - ein weiterer Versuch

Hallo Habr!

Ich habe vor kurzem beschlossen, herauszufinden, wie eine clientseitige Webanwendung, die vollständig auf Vanilla-Webkomponenten geschrieben ist, ohne Verwendung von Frameworks aussehen sollte. Es stellte sich als ziemlich funktionierend heraus. Am Ende skizzierte ich die mobile PWA-Vorlage , die ich jetzt in Prototypen verwende. Ausgehend von folgenden Räumlichkeiten:

  • DOM ist ein Staat. Da wir keinen Rahmen haben, vergessen wir sofort den Funktionalismus und kehren zum imperativen OOP zurück. Webkomponenten sind langlebige DOM-Knoten, die ihren Status kapseln und über eine öffentliche API verfügen. Sie werden nicht neu erstellt, sondern geändert. Dies bedeutet, dass wir das DOM nicht nur als Repräsentation, sondern auch als Repository für Geschäftsobjekte betrachten sollten. Daher müssen wir eine Hierarchie von Komponenten erstellen, die die Bequemlichkeit ihrer Interaktion berücksichtigt.
  • Das Zusammenspiel der Komponenten. Komponenten können über direkte Anrufe, Rückrufaustausch oder über Benutzer-DOM-Ereignisse nach oben / unten interagieren. Die letztere Methode ist am meisten vorzuziehen, da sie das gegenseitige Eingreifen (Koppeln) verringert und das Diagramm der Bindungen anordnet (siehe Beispiel unten).
    DOM-Ereignisse funktionieren nur innerhalb der Hierarchie. Sie können von unten in der Ahnenkette angezeigt oder an alle Nachkommen gesendet werden. In anderen Fällen wird die Standard-Browser-API verwendet, um die Komponente zu adressieren: document.querySelector ('page-home'), und einige Komponenten können sich im Fenster registrieren und global verwendet werden: APP.route ('page-login').
  • Innere Stile. Shadow DOM wird nicht verwendet, daher erben Komponenten globale Stile, können aber auch eigene Stile haben. Da es unwahrscheinlich ist, dass <style scoped> in naher Zukunft implementiert wird, müssen Sie das Präfix des Komponentennamens verwenden, um den internen Stil zu deklarieren. Dies ist jedoch stillschweigend und funktioniert einwandfrei (siehe Beispiel unten).
  • HTML/DOM. DOM — , HTML (value, checked, innerHTML contenteditable=«true» ..). JS , — / , ( ). , , this.pass — , <input> . , DOM, , , , .
  • Navigation. Die Seitenkomponenten befinden sich im <main> -Container und werden nach ihrer Erstellung nicht gelöscht, sondern einfach ausgeblendet. Auf diese Weise können Sie die Navigation mit location.hash implementieren, und die Standardbrowser-Schaltflächen funktionieren ordnungsgemäß. Wenn Sie zu einer vorhandenen Komponente navigieren, wird die Methode onRoute () aufgerufen, mit der Sie die Daten aktualisieren können.

Anwendungsstruktur


Unsere Anwendung besteht aus:

  • die Root-Komponente <app-app>, auf die über window.APP zugegriffen werden kann und die einen Seitenrouter und globale Funktionen enthält;
  • Bedienfelder mit Kontextschaltflächen (ich habe sie nicht in eine separate Komponente eingefügt, sondern zur Vereinfachung der Ereignisbehandlung in das <app-app> -Layout aufgenommen);
  • Dropdown-Menü (separate Komponente);
  • Der <main> -Container, in den die Seitenkomponenten eingefügt werden: <page-home>, <page-login>, <page-work> beim Öffnen.

Die Seiten sind mit Hin- und Her-Navigation gestapelt. Darüber hinaus zeigen wir Datenflüsse von unten nach oben und von oben nach unten:

  • Der Autorisierungsstatus und der aktuelle Benutzername werden in der Komponente <app-app> gespeichert, kommen jedoch über ein Popup-Ereignis von der Komponente <page-login>.
  • Ein Timer tickt in der <app-app> -Komponente, die den aktuellen Wert über ein Broadcast-Ereignis nach unten sendet, das nur vom Nachkommen von <page-work> abgefangen wird.

Durch Popup-Ereignisse wird auch die Installation kontextsensitiver Schaltflächen im Anwendungsbereich implementiert. Die Schaltflächen selbst werden zusammen mit den Handlern in den Seitenkomponenten erstellt und dann nach oben gesendet, wo sie auf App-Ebene abgefangen und in das Bedienfeld eingefügt werden.

Implementierung


Um mit dem internen DOM der Komponente zu arbeiten und nachgelagerte Ereignisse zu senden, wird die winzige Bibliothek WcMixin.js verwendet - weniger als 200 Codezeilen, von denen die Hälfte (Vereinheitlichung von Benutzereingabeereignissen) auch verworfen werden kann. Alles andere ist reine Vanille. Eine typische Komponente (Autorisierungsseite) sieht folgendermaßen aus:

import wcmixin from './WcMixin.js'

const me = 'page-login'
customElements.define(me, class extends HTMLElement {
   _but = null

   connectedCallback() {
      this.innerHTML = `
         <style scoped>
            ${me} {
               height: 90%; width: 100%;
               display: flex; flex-direction: column;
               justify-content: center; align-items: center;
            }
            ${me} input { width: 60%; }
         </style>
         <input w-id='userInp/user' placeholder='user'/> 
         <input w-id='passInp/pass' type='password' placeholder='password'/>
      `
      wcmixin(this)

      this.userInp.oninput = (ev) => {
         this.bubbleEvent('login-change', {logged: false, user: this.user})
      }

      this.passInp.onkeypress = (ev) => {
         if (ev.key === 'Enter') this.login()
      }
   }

   onRoute() {
      this.userInp.focus()
      this._but = document.createElement('button')
      this._but.innerHTML = 'Log in<br>⇒'
      this._but.onclick = () => this.login()
      this.bubbleEvent('set-buts', { custom: [this._but] })
   }

   async login() {
      APP.msg = 'Authorization...'
      this._but.disabled = true
      setTimeout(() => {
         this._but.disabled = false
         if (this.user) {
            this.bubbleEvent('login-change', {logged: true, user: this.user})
            APP.route('page-work')
         } else {
            APP.msg = 'Empty user !'
            this.userInp.focus()
         }
      }, 1500)
   }
})

Zunächst sehen wir hier die lokalen Stile der Komponente. Zweitens wurde dem HTML-Markup das einzige nicht standardmäßige Attribut w-id = `` userInp / user '' hinzugefügt. Die Funktion wcmixin () verarbeitet alle mit diesem Attribut gekennzeichneten Elemente und fügt der aktuellen Komponente Variablen hinzu: this.userInp bezieht sich auf das <input> -Element selbst (mit dem Sie den Handler aufhängen können), und this.user ist der Wert des Elements (Benutzername). Wenn kein Zugriff auf das Element erforderlich ist, können Sie w-id = `` / user '' angeben, und nur der Wert wird erstellt.

Bei der Eingabe eines Benutzernamens senden wir den aktuellen Wert über ein Popup-Ereignis nach oben, erstellen eine kontextsensitive Schaltfläche in der onRoute () -Methode und senden ihn auch nach oben.

Es ist wichtig, dass die Autorisierungskomponente nichts über die höheren Komponenten der Anwendung / des Panels weiß, dh, sie ist nicht angeschlossen. Er sendet einfach Ereignisse nach oben, und wer sie abfängt, ist dem Entwickler überlassen. Der Empfang von Ereignissen aus der Anwendung <app-app> in der Komponente <page-work> wird auf folgende Weise implementiert:

import wcmixin from './WcMixin.js'

const me = 'page-work'
customElements.define(me, class extends HTMLElement {

   connectedCallback() {
      this.innerHTML = `
         <p w-id='/msg'>Enter text:</p>
         <p w-id='textDiv/text' contenteditable='true'>1)<br>2)<br>3)</p>
      `
      wcmixin(this)

      this.addEventListener('notify-timer', (ev) => {
         this.msg = `Enter text (elapsed ${ev.val}s):`
      })
   }

   async onRoute() {
      this.textDiv.focus()
      document.execCommand('selectAll',false,null)
      const but = document.createElement('button')
      but.innerHTML = 'Done<br>⇒'
      but.onclick = () => alert(this.text)
      this.bubbleEvent('set-buts', { custom: [but] })
   }
})

Und in die <app-app> -Komponente schreiben wir:

setInterval(() => {
   this._elapsed += 1
   this.drownEvent('notify-timer', this._elapsed)
}, 1000)

Die <app-app> -Komponente weiß auch nichts über Seitenkomponenten, die ihren Zähler verwenden möchten, dh sie ist nicht an ihre Nachkommen gebunden. Es reicht aus, wenn sich der Entwickler auf die Ereignissignaturen einigt. DOM-Ereignisse sind leichtgewichtig, absteigende Ereignisse werden nur an Webkomponenten (und nicht an einfache Elemente) gesendet, und aufsteigende Ereignisse durchlaufen standardmäßig die gesamte Ahnenkette.

Das ist eigentlich alles, was ich sagen wollte.

→  Vollständiger Projektcode

Einwände und Vorschläge


Es wird mir oft widersprochen, dass ein solcher Ansatz Geschäftslogik und Anzeige in einer „dicken“ Komponente mischt, was gegen allgemein akzeptierte Muster verstößt. Wir sprechen jedoch nur über die Logik zum Anzeigen und Überprüfen von Benutzereingaben. Der Rest der Geschäftslogik kann problemlos in separate JS-Klassen oder sogar Dienste verschoben werden - mit eigenen Hierarchien und Interaktionsmethoden.

Warum ist das notwendig? Es gibt immer noch ein Rendering-Leistungsproblem (Garbage Collection ist nicht kostenlos), und ein zwingender Ansatz mit nativen Tools ist immer schneller und weniger ressourcenintensiv als deklarativ / funktional mit JS-Bibliotheken und VDOM. Wenn Sie möchten, bin ich bereit, mit einem Vertreter eines beliebigen Frameworks über eine vereinbarte TOR zu konkurrieren, wenn Sie die Benchmarking-Funktion übernehmen (ich kann dies schlecht tun).

Vielen Dank für Ihre Aufmerksamkeit.

All Articles