Teil 7 - Implementierung eines Architekturkonzepts mit DOM APIs

Dieses Lab ist Teil der Amenity Finder Serie, einer umfassenden Anleitung, die Schritt für Schritt zeigt, wie man eine Web-Applikation mithilfe von Web-Components entwickelt. In jedem Teil der Serie werden spezifische Aspekte der Entwicklung behandelt und es werden bewährte Methoden und Techniken vermittelt, um eine moderne und skalierbare Webanwendung aufzubauen.

Im siebten und letzten Teil der Serie setzen wir ein typisches Architekturkonzept mithilfe der DOM APIs um. Wir nutzen die Stärken der Web-Components und verwenden die DOM APIs, um eine saubere und effiziente Struktur für unsere Applikation zu erstellen.

Vorbereitung

Ziele

In diesem letzten Lab der Serie werden wir uns auf die folgenden Ziele konzentrieren:

  • Typische Architekturkonzepte von Applikationen kennenlernen: Wir werden uns mit typischen Architekturkonzepten vertraut machen, die in der Entwicklung von Webapplikationen weit verbreitet sind. Dabei werden wir uns insbesondere auf Konzepte wie Lazy Loading, Dependency Injection und den Umgang mit dem Pending State fokussieren. Wir werden verstehen, wie Lazy Loading verwendet werden kann, um Ressourcen und Komponenten nur dann zu laden, wenn sie benötigt werden. Die Nutzung von Dependency Injection ermöglicht es uns, Abhängigkeiten zwischen Komponenten zu verwalten und die Wiederverwendbarkeit und Testbarkeit zu verbessern. Zudem werden wir lernen, wie wir den Pending State in unserer Applikation handhaben können, um Benutzerfeedback während asynchroner Vorgänge zu geben.
  • Ausgewählte Konzeptbeispiele implementieren: Wir werden konkrete Beispiele und Best Practices für die Implementierung der genannten Architekturkonzepte in unserer Amenity Finder Applikation umsetzen. Dabei werden wir den AmenityFinder als Provider einsetzen, um Daten an andere Komponenten bereitzustellen. Die ResultsView wird als Requester fungieren und die benötigten Daten vom AmenityFinder anfordern. Zudem werden wir den Pending State berücksichtigen, um Benutzerfeedback während der Datenabfrage darzustellen. Durch die Implementierung dieser Konzepte werden wir eine bessere Strukturierung und Organisation unserer Applikation erreichen, die Wartbarkeit und Erweiterbarkeit verbessern und die Benutzererfahrung optimieren.

Schritte

Das Architekturkonzept mithilfe von DOM APIs wird in fünf Schritten umgesetzt:

  1. Architekturkonzepte von Applikationen
  2. Lazy Loading
  3. JavaScript Mixins
  4. Dependency Injection
  5. Pending State

Branch

Der siebte Teil des Amenity Finder Labs ist auf GitHub verfügbar.


Los geht’s!

Architekturkonzepte von Applikationen

Schreiben wir eine Applikation für den Browser, werden wir auf ein paar wiederkehrende Architekturkonzepte treffen. Hier eine unvollständige Liste von Konzepten, die vielen Applikationen und Frameworks zu grunde liegen:

  • Komponenten (✓)
  • Templating (✓)
  • Reaktivität (✓)
  • Applikation (Orchestrierung) (✓)
  • Routing (✓)
  • State Management
  • Dependency Injection
  • Scheduling
  • Asynchrones Laden von Programmcode (Lazy Loading)
  • Asynchrone Tasks und Daten (Pending State)
  • Services
  • usw.

Einige dieser Konzepte haben wir in den bisherigen Lektionen bereits abgedeckt (✓). Wir haben eine Applikation, die als Orchestrator fungiert. Unsere Komponenten sind mit dem nativen Komponenten-Modell der Webplattform geschrieben. Durch Lit haben wir «Reactive Properties» und den Punkt «Templating» mithilfe von Template-Strings eingebaut. Für das Routing haben wir (optional) mit Page.js eine kleine Library verwendet.

In den nächsten drei Kapiteln schauen wir nun drei ausgewählte Konzepte näher an und implementieren diese ohne zusätzliche Libraries in unserer Applikation:

  • Lazy Loading
  • Dependency Injection
  • Pending State

Lazy Loading

Unsere Applikation besteht im Moment aus vier verschiedenen Views. Im Moment werden diese im AmenityFinder.js alle gleichzeitig importiert:

import './views/HomeView.js';
import './views/ResultsView.js';
import './views/SearchView.js';

Das Problem daran ist, dass alle diese Files beim Laden der Applikation geladen werden. Wir sehen dies auch im folgenden Bild:

Static Imports

Es wäre besser, wenn wir Files zu den einzelnen Views erst dann laden, wenn wir sie brauchen—also, wenn wir durch das Routing auf die jeweilige View geleitet werden. Um dieses Problem zu lösen, können wir Dynamic Module Loading (Dynamic Imports) und Lit Directives verwenden.

Wir wollen in unseren Lit Templates folgendes Konstrukt verwenden:

lazyLoad(import('./views/HomeView.js'), html`<home-view></home-view>`);

und schreiben dazu eine entsprechende lazyLoad Directive. Wir legen sie in einem Verzeichnis directives an und erstellen darin das File lazyLoadDirective.js mit dem folgenden Inhalt:

import { directive } from 'lit/directive.js';
import { AsyncDirective } from 'lit/async-directive.js';

class LazyLoadDirective extends AsyncDirective {
  render(importPromise, value) {
    // TODO: Implement signaling pending state…
    return value;
  }
}

export const lazyLoad = directive(LazyLoadDirective);

Im Moment machen wir noch nichts mit dem importPromise Parameter. Wir haben dazu im Code noch ein TODO vermerkt und werden später beim Pending State noch eine Erweiterung der Direktive bauen. Mit der [setValue API] können wir in unserer Directive den Wert des dazugehörigen «Parts» setzen. Ein Part repräsentiert in Lit das mit einem Binding assoziierte DOM Element. In der Direktive setzen wir also in this.setValue(value) als Inhalt des DOM Elements den Wert (hier html`<home-view></home-view>`) des value Parameter.

Wir können dies machen, weil der Browser zwar anfänglich nichts mit <home-view></home-view> anfangen kann, sobald aber der dynamische Import aufgelöst ist und er das Custom Element <home-view></home-view> «kennt», wird er es darstellen. Wir passen jetzt noch den AmenityFinder entsprechend an:

Unsere Direktive importieren und die statischen import statements entfernen:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
 import '@material/mwc-list/mwc-list.js';
 import '@material/mwc-list/mwc-list-item.js';

-import './views/HomeView.js';
-import './views/ResultsView.js';
-import './views/SearchView.js';
+import { lazyLoad } from './directives/lazyLoadDirective.js';

In der _renderCurrentView Methode die Direktive einbauen:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js

   _renderCurrentView() {
     switch (this.currentView) {
       case 'home':
-        return html`<home-view></home-view>`;
+        return lazyLoad(import('./views/HomeView.js'), html`<home-view></home-view>`);
       case 'search':
-        return html`<search-view
-          .latitude="${this.latitude}"
-          .longitude="${this.longitude}"
-          .radius="${this.radius}"
-          @execute-search="${(e) => this._onExecuteSearch(e)}"
-        ></search-view>`;
+        return lazyLoad(
+          import('./views/SearchView.js'),
+          html`<search-view
+            .latitude="${this.latitude}"
+            .longitude="${this.longitude}"
+            .radius="${this.radius}"
+            @execute-search="${(e) => this._onExecuteSearch(e)}"
+          ></search-view>`
+        );
       case 'results':
-        return html`<results-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}">
-          <p><a href="${`/search/${this.latitude}/${this.longitude}/${this.radius}`}">← Back to search</a></p>
-        </results-view>`;
+        return lazyLoad(
+          import('./views/ResultsView.js'),
+          html`<results-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}">
+            <p><a href="${`/search/${this.latitude}/${this.longitude}/${this.radius}`}">← Back to search</a></p>
+          </results-view>`
+        );
       default:
         return ``;
     }

Im Browser sehen wir jetzt, dass die einzelnen Dateien zu den Views erst dann geladen werden, wenn wir zu den jeweiligen Seiten navigieren:

Dynamische Imports

JavaScript Mixins

Für die Umsetzung der weiteren Konzepte werden wir JavaScript Mixins verwenden:

A mixin is an abstract subclass; i.e. a subclass definition that may be applied to different superclasses to create a related family of modified classes.

Wir können mithilfe von Mixins «Kompositionen» von Features in einem Objekt zusammenführen. Der Artikel «Real Mixins with JavaScript Classes» beschreibt, was Mixins sind und wie man sie in JavaScript am besten implementieren kann. In JavaScript kann man sich die folgenden zwei Features der Sprache bei der Implementation zunutze machen:

  1. class kann im JavaScript sowohl als expression als auch statement verwendet werden. Als eine expression wird jedes Mal eine neue Klasse zurückgegeben (eine Art «Factory»).
  2. Bei der extends Klausel können in JavaScript beliebige Ausdrücke verwendet werden, also auch solche, die Klassen oder Konstruktoren zurückgeben.

Wir können also Mixins als eine Funktion definieren, die als Parameter eine Oberklasse entgegennimmt und eine neue Unterklasse davon erzeugt:

let M = (superclass) => class extends superclass {
  foo() {
    console.log('Hello from Mixin «M»');
  }
};

Dieses Mixin können wir nun in der extends Klausel wie folgt verwenden:

class B extends MyMixin(A) {
  /* ... */
}

Objekte von B haben jetzt eine foo() Methode durch die Mixin-Vererbung:

let c = new B();
c.foo(); // prints "Hello from Mixin «M»"

Unser Beispiel grafisch dargestellt:

Mixins Beispiel


Dependency Injection

Dependency Injection funktioniert in den Grundsätzen oft ähnlich: ein zentraler Dependency Injection «Container» weiss, welche Abhängigkeiten unter welchen Namen verfügbar sind. Der Container ist auch für die Instanziierung dieser Dependencies zuständig. Komponenten im System können die Abhängigkeiten von dieser zentralen Stelle anfragen und erhalten die entsprechenden Instanzen. Wir definieren das Konzept eines Provider und eines Requester. Der Requester kann Abhängigkeiten anfragen welche allfällige Provider erfüllen können (nicht aber müssen).

Grafik Provider & Requester

Den Provider und Requester implementieren wir mithilfe von Mixins wie folgt:

src/mixins/ProviderMixin.js
/**
 * Simple implementation of the "dependency resolution protocol".
 *
 * A provider can provide instances of services.
 *
 * @param Base Base class to extend
 * @property {Map} __instances
 * @constructor
 */
export const Provider = (Base) =>
  class extends Base {
    constructor() {
      super();

      this.__instances = new Map();

      this.addEventListener('request-instance', (event) => {
        const { key } = event.detail;
        if (this.__instances.has(key)) {
          // eslint-disable-next-line no-param-reassign
          event.detail.instance = this.__instances.get(key);

          // We do not need to propagate the event any further, since we've reached
          // a provider that takes care of providing an instance.
          event.stopPropagation();
        }
      });
    }

    /**
     * Register an instance under the given key.
     *
     * @param key
     * @param instance
     */
    provideInstance(key, instance) {
      this.__instances.set(key, instance);
    }
  };
src/mixins/RequesterMixin.js
/**
 * Requester can request instances by a given key.
 *
 * @param Base Base class to extend
 * @constructor
 */
export const Requester = (Base) =>
  class extends Base {
    requestInstance(key) {
      const event = new CustomEvent('request-instance', {
        detail: { key },
        bubbles: true,
        composed: true,

        // Provider should be able to cancel any further event propagation
        cancelable: true,
      });

      this.dispatchEvent(event);

      const { instance } = event.detail;
      return instance;
    }
  };

AmenityFinder als Provider

Wir kennen mit dem AmenityFinder bereits eine Art zentralen Orchestrator. Wir können unser Provider Mixin daher an dieser Stelle einbinden. Als Abhängigkeit, welche den «Requestern» zur Verfügung stehen soll, können wir unsere API-Klasse OverpassApi.js verwenden. Wir registrieren die Abhängigkeit unter dem Namen api:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
 import '@material/mwc-list/mwc-list-item.js';

 import { lazyLoad } from './directives/lazyLoadDirective.js';
+import { Provider } from './mixins/ProviderMixin.js';
+import { OverpassApi } from './services/OverpassApi.js';

-export class AmenityFinder extends LitElement {
+export class AmenityFinder extends Provider(LitElement) {
   static get properties() {
     return {
       showSidebar: { type: Boolean },

     this.radius = 1000;

     this._initializeRoutes();
+
+    // Provide instances
+    this.provideInstance('api', new OverpassApi());
   }

   render() {

ResultsView als Requester

Die OverpassApi Klasse hatten wir bis jetzt in der ResultsView verwendet. Dort können wir den direkten Import der API entfernen und in der ResultView die API über das Requester Mixin und unsere Dependency Injection anfragen:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
 import { css, html, LitElement } from 'lit-element';

-import { OverpassApi } from '../services/OverpassApi.js';
-
 import '../components/AmenityBrowser.js';
 import '../components/AmenityItem.js';
 import { distanceBetween } from '../utils/geolocation.js';
+import { Requester } from '../mixins/RequesterMixin.js';

-export class ResultsView extends LitElement {
+export class ResultsView extends Requester(LitElement) {
   static get properties() {
     return {
       latitude: { type: String },


     super();

     this.results = [];
-    this.api = new OverpassApi();
   }

   async connectedCallback() {
     super.connectedCallback();
+    this.api = this.requestInstance('api');
     await this._fetchResults();
  }

Damit haben wir den direkten Import in der ResultsView ausgetauscht durch eine vom Container zur Verfügung gestellte Abhängigkeit. Ändert sich das Interface (die Methode getNodeByLatLng()) unserer API-Klasse nicht, können wir die Implementation ganz einfach im AmenityFinder austauschen, ohne die ResultView anfassen zu müssen! 🎉

WICHTIG: Weil unser Requester die Instanz über DOM Events anfordert, werfen wir den Event im connectedCallback Lifecycle-Hook einer Webkomponente. Hier ist sichergestellt, dass sich die Komponente zum Zeitpunkt des Events im DOM befindet.

Wir führen noch eine weitere Optimierung ein. In der bisherigen Implementation holen wir Resultate über _fetchResults im connectedCallback. Die _fetchResults Methode ihrerseits verwendet als Parameter dielatitude, longitudeundradiusProperties. Nun kann es sein, dass diese imconnectedCallbacknoch nicht definiert sind (resp.undefinedsind) und wir so keine Resultate vom API-Call zurückbekommen. Wir haben mehrere Möglichkeiten, diesem entgegenzuwirken. Zwecks Einfachheit holen wir die Resultate mit_fetchResults erst dann, wenn die Komponente einmal gerendert wurde. Zu diesem Zeitpunkt sollten die notwendigen Properties bereits definiert sein:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js

-  async connectedCallback() {
+  connectedCallback() {
     super.connectedCallback();
     this.api = this.requestInstance('api');
-    await this._fetchResults();
+  }
+
+  async firstUpdated(changedProperties) {
+    super.firstUpdated(changedProperties);
     await this._fetchResults();
   }

Eine bessere Möglichkeit wäre, z.B. über den updated() Lit Lifecycle-Hook zu überprüfen, ob die notwendigen Properties latitude, longitude und radius definiert sind und die Resultate erst dann holen, wenn alle drei als Voraussetzung gegeben sind.


Pending State

Es gibt in einer Applikation diverse Szenarien, in denen Komponenten von asynchronen Tasks abhängig sind. Komponenten können ihre Implementierung, ihren Inhalt oder ihre Abhängigkeiten «lazy» laden, auf Benutzereingaben reagieren oder ihre Daten asynchron rendern usw. Den Status solcher asynchronen Arbeiten möchte man im Komponenten-Baum an übergeordnete (oder generell an andere) Komponenten kommunizieren. Diese Komponenten können den Status dieser Arbeiten dem Benutzer im UI anzeigen, zum Beispiel mittels Loading-Indicators.

pending-state-protocol

Justin Fagnani, ein Google-Mitarbeiter aus dem Polymer Projekt und einer der Ersteller und Maintainer von Lit, schlägt als Lösung für diese Anforderung das «pending-state-protocol»1 vor. Sein Vorschlag baut auf Promises und nativen DOM-Events mittels der CustomEvent API auf. Er lässt sich wie folgt zusammenfassen:

  • Komponenten können einen pending-state-Event feuern, sobald asynchrone Tasks starten
  • Der pending-state-Event trägt als Payload ein Promise
  • Ein Container (eine Komponente) behält die Zahl der ausstehenden Promises im Auge

Implementation

Eine Beispiel-Implementation von diesem Protokoll können wir in unserer Applikation, ähnlich wie schon bei der Dependency Injection, mithilfe von Mixins erstellen. Wir definieren ein «Pending-Container» Mixin für den Container, der unsere ausstehenden asynchronen Tasks tracken soll, wie folgt:

src/mixins/PendingContainerMixin.js
/**
 * Container that oversees all pending requests.
 *
 * @param base Base class to extend
 * @property {boolean} __hasPendingChildren
 * @property {number} __pendingCount
 * @constructor
 */
export const PendingContainer = (base) =>
  class extends base {
    static get properties() {
      return {
        __hasPendingChildren: { type: Boolean },
        __pendingCount: { type: Number },
      };
    }

    constructor() {
      super();

      this.__hasPendingChildren = false;
      this.__pendingCount = 0;

      this.addEventListener('pending-state', async (e) => {
        this.__hasPendingChildren = true;
        this.__pendingCount += 1;
        await e.detail.promise;
        this.__pendingCount -= 1;
        this.__hasPendingChildren = this.__pendingCount !== 0;
      });
    }
  };

Dieses Mixin erweitert eine beliebige Klasse um folgende Funktionalität: wir binden uns auf den pending-state-Event und tracken mithilfe der hasPendingChildren und pendingCount Variablen, ob irgendwelche Tasks ausstehend sind. Dabei gehen wir davon aus, dass ein solcher Event im e.detail.promise ein entsprechendes Promise trägt, auf wessen Erledigung wir warten können. Abhängig vom Wert der hasPendingChildren Variable können im UI dem Benutzer ausstehende Arbeiten entsprechend signalisieren.

Ein guter Ort für eine solche Anzeige ist unsere AmenityFinder Komponente, die jetzt schon als eine Art Orchestrator dient.

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
 import { lazyLoad } from './directives/lazyLoadDirective.js';
 import { Provider } from './mixins/ProviderMixin.js';
 import { OverpassApi } from './services/OverpassApi.js';
+import { PendingContainer } from './mixins/PendingContainerMixin.js';

-export class AmenityFinder extends Provider(LitElement) {
+export class AmenityFinder extends PendingContainer(Provider(LitElement)) {

Dabei sehen wir auch, dass Mixins kombiniert werden können. In unserem Fall haben wir die AmenityFinder Klasse sowohl mit dem Provider aber auch mit dem neuen PendingContainer Mixin erweitert.

Jetzt brauchen wir noch eine geeignete UI-Komponente für die Anzeige der ausstehenden Tasks über hasPendingChildren. Wir können dafür die <mwc-linear-progress> MWC verwenden. Wir installieren die Komponente:

$ npm install @material/mwc-linear-progress@^0.22.1

und bauen sie entsprechend im AmenityFinder ein mit:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
@@ -4,6 +4,7 @@
 import '@material/mwc-drawer';
 import '@material/mwc-top-app-bar';
 import '@material/mwc-icon-button';
+import '@material/mwc-linear-progress';
 import '@material/mwc-list/mwc-list.js';
 import '@material/mwc-list/mwc-list-item.js';

und:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
   render() {
     return html`
+      <mwc-linear-progress indeterminate .closed="${!this.__hasPendingChildren}"></mwc-linear-progress>
       <mwc-drawer hasHeader type="modal" .open="${this.showSidebar}" @MDCDrawer:closed="${this._closeSidebar}">
         <span slot="title">Navigation</span>
         <mwc-list>

Dabei verwenden wir das closed Property der <mwc-linear-progress> MWC um den Indicator entweder anzuzeigen oder mittels opacity: 0 auszublenden. Damit die neue Progress-Bar noch schöner dargestellt wird, erweitern wir das CSS von AmenityFinder mit:

mwc-linear-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
}

Asynchrone Tasks signalisieren

Unsere Applikation kann nun ausstehende asynchrone Tasks mithilfe des pending-state-protocol anzeigen. Was uns jetzt noch fehlt, sind die eigentlichen pending-state-Events an den Orten, wo in unserer Applikation die asynchronen Tasks anfallen. Dabei haben wir an den folgenden Orten geeignete Stellen, um die Signalisation solcher asynchronen Tasks zu implementieren:

  • In der lazyLoad-Direktive (asynchrones Laden von Code)
  • In der <leaflet-map> im tiles-loading-Event (Laden von Map-Tiles)
  • Im OverpassApi-Service beim Laden der Suchresultaten (API-Call in der getNodeByLatLng() Methode)

Wir implementieren als einfaches Beispiel des pending-state-Event beim Laden der Map-Tiles in der <leaflet-map>. Dafür passen wir die SearchView an:

--- src/views/SearchView.js
+++ src/views/SearchView.js
@@ -58,6 +58,17 @@
         .longitude="${this.longitude}"
         .radius="${this.radius}"
         @center-updated="${this._updateLatitudeLongitudeFromMap}"
+        @tiles-loading="${e => {
+          this.dispatchEvent(
+            new CustomEvent('pending-state', {
+              composed: true,
+              bubbles: true,
+              detail: {
+                promise: e.detail.promise,
+              },
+            })
+          );
+        }}"
         updatecenteronclick
       ></leaflet-map>
     `;

Das Gleiche können wir auch noch im AmenityBrowser machen. Beim Manipulieren der Karte (Verschieben, Zoom, usw.) sollten die Progress-Bar das Laden neuer Map-Tiles signalisieren:

Animation der Progress-Bar

Die Implementation im OverpassApi-Service ist trivial. Ein Beispiel dafür sowie die Erweiterung der lazyLoad-Direktive um Signalisation des pending-state sind im Branch zu dieser Lektion implementiert.


Glückwunsch!

Du hast den siebten und letzten Teil der Amenity Finder Serie erfolgreich gemeistert. In diesem Teil haben wir ein typisches Architekturkonzept mithilfe von DOM APIs implementiert. Du hast gelernt, wie man die DOM APIs nutzt, um eine strukturierte und gut organisierte Applikationsarchitektur zu erstellen. Durch die Verwendung dieser Konzepte und Best Practices wird unser Code wartbarer, erweiterbarer und besser lesbar. Mit diesem Abschluss hast du die Amenity Finder Serie komplettiert und verfügst über das Wissen und die Fähigkeiten, um Web Components effektiv einzusetzen. Herzlichen Glückwunsch zu deinem Erfolg!


Footnotes

1 In der Zwischenzeit wurde das pending-state-protol im community-protocols repository der Web Components Community Group durch das pending-task-protocol Proposal ersetzt.