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:
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:
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:
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.
- Gilad Bracha and William Cook, Mixin-based Inheritance
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:
class
kann im JavaScript sowohl alsexpression
als auchstatement
verwendet werden. Als eineexpression
wird jedes Mal eine neue Klasse zurückgegeben (eine Art «Factory»).- 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:
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).
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
, longitude
undradius
Properties. Nun kann es sein, dass diese imconnectedCallback
noch nicht definiert sind (resp.undefined
sind) 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 einPromise
- 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>
imtiles-loading
-Event (Laden von Map-Tiles) - Im
OverpassApi
-Service beim Laden der Suchresultaten (API-Call in dergetNodeByLatLng()
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:
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. ↩