Teil 3 - Seitenunterteilung und Routing

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 dritten Teil der Serie unterteilen wir die Amenity Finder Applikation in einzelne Seiten (URLs) und implementieren das Routing. Wir sorgen dafür, dass unsere Applikation mehrere Ansichten unterstützt und der Benutzer nahtlos zwischen ihnen navigieren kann.

VorschauApplikation mit Routing über Navigation

Wenn du dieses Lab bereits durchgearbeitet hast, wirst du im vierten Teil der Amenity Finder Serie lernen, wie wir die Suchseite entwickeln und die Parametrisierung implementieren, um Suchanfragen abzusetzen.

Vorbereitung

Ziele

Die Hauptziele dieses Labs sind:

  • Implementierung eindeutiger URLs für jede einzelne Seite (View) der Applikation: Wir werden eine Routing-Funktionalität einrichten, die es ermöglicht, auf jede Seite der Applikation über eine eindeutige URL zuzugreifen. Dadurch können Benutzer direkt zu einer bestimmten Seite navigieren, indem sie die entsprechende URL eingeben oder einem Link folgen.
  • Bereitstellung einer Navigation, die es dem Benutzer ermöglicht, zwischen den einzelnen Seiten zu navigieren: Wir werden eine Navigationsleiste oder ein Menü erstellen, die es dem Benutzer ermöglicht, auf einfache Weise zwischen den verschiedenen Seiten der Applikation zu wechseln. Dies erleichtert die Navigation und verbessert die Benutzererfahrung, indem es den Zugriff auf verschiedene Funktionen und Inhalte ermöglicht.

Branch

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

Wenn du weitere Informationen zu den behandelten Themen suchst, findest du hilfreiche Ressourcen unter den nachfolgenden Links:


Aufgabenstellung

Du hast jetzt die Möglichkeit, die Aufgabenstellung eigenständig zu lösen. Wenn du deine Lösung abgeschlossen hast oder wenn du sofort weiterlesen möchtest, stellen wir im nächsten Abschnitt unseren Lösungsvorschlag für den dritten Teil der Amenity Finder Serie vor.

Aufgaben

  • Definiere Views als eigene Web-Components: Erstelle die Web-Components <home-view>, <search-view> und <results-view>, um die verschiedenen Ansichten der Applikation darzustellen. Jede dieser Komponenten repräsentiert eine spezifische Ansicht der Applikation.
  • Wechsel der Navigation über das currentView Property in der AmenityFinder-Komponente:
    • Binde das click-Event der <mwc-list-item>-Elemente an eine neue Methode.
    • Diese Methode sollte den Aufruf der Methode _navigateTo(viewName) auslösen.
    • Die _navigateTo-Methode ist dafür zuständig, den Wert des currentView-Properties zu aktualisieren.
    • Erweitere die render()-Methode in der AmenityFinder-Komponente um den Aufruf der Methode _renderCurrentView().
    • Abhängig vom Wert des currentView-Properties sollte das entsprechende Custom Element angezeigt werden.

Optional

Durch diese optionale Erweiterung können die einzelnen Seiten (Views) der Applikation über eindeutige URLs abgerufen werden.

  • Um das Routing zu implementieren, ist es erforderlich, das Routing-Modul Page.js zu installieren.
  • Nach der Installation musst du die Routen für die verschiedenen Seiten definieren. In diesem Fall sind die folgenden Routen relevant:
    • Die Startseite: /
    • Die Suchseite: /search
    • Die Ergebnisseite: /results
  • Zum Schluss sollst du die Navigation noch mit den diesen definierten Routen verknüpfen.

Unser Lösungsvorschlag

Schritte

Das Routing der Applikation wird in vier Schritten implementiert:

  1. Applikation in separate Seiten unterteilen
  2. Routing einbauen (optional)
  3. Deployment für Routing anpassen (optional)
  4. Layout optimieren (optional)

Applikation in separate Seiten unterteilen

Unsere Applikation hat bereits eine Navigation. Wir unterscheiden darin zwischen 3 Views. Jede dieser Views soll unter einer konkreten URL erreichbar sein. Wenn ein Benutzer eine dieser URLs in seinem Browser eingibt, soll er auf dieser View in der Applikation landen. Wir definieren die folgenden Views und URLs:

ViewURLBeschreibung
Home/Startseite
Search/searchSeite für die Suche
Results/resultsSeite für Suchresultate

Web-Components für Views erstellen

Wir legen als Erstes für jede View eine eigene Webkomponente (Klasse) an. Damit wir zwischen wiederverwendbaren Komponenten und den Views bereits in der Ordnerstruktur besser unterscheiden können, legen wir die Klassen in einem eigenen Verzeichnis src/views an. Für jede View erstellen wir eine Datei mit dem folgenden Inhalt und passen die Namen und den Inhalt der render() Methode für an die jeweilige View an:

import { html, LitElement } from 'lit';

export class HomeView extends LitElement {
  render() {
    return html`Home…`;
  }
}

customElements.define('home-view', HomeView);

Unsere Verzeichnisstruktur im src Ordner sieht nun ungefähr so aus:

src/
└── views/
    ├── HomeView.js
    ├── SearchView.js
    └── ResultsView.js

Aktuelle View in der Applikation definieren

Damit wir den Zustand für die aktuelle View in der Applikation tracken können, definieren wir ein currentView Property. Das Default soll bei diesem Property der Wert home sein. Als Default-View soll nämlich unsere Startseite angezeigt werden:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
   static get properties() {
     return {
       showSidebar: { type: Boolean },
+      currentView: { type: String },
     };
   }

und

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
   constructor() {
     super();
     this.showSidebar = false;
+    this.currentView = 'home';
   }

   render() {

Nun müssen wir noch die ausgewählte View als Hauptinhalt in unserer Applikation darstellen. Dafür müssen wir in der render() Methode den Teil:

<div>
  <p>Main Content!</p>
</div>

mit dem Inhalt der ausgewählten View austauschen. Wir erstellen eine neue Methode in der AmenityFinder Klasse _renderCurrentView(). Diese Methode soll, abhängig vom Wert des currentView Property, den Inhalt für die ausgewählte View liefern:

_renderCurrentView() {
 switch (this.currentView) {
  case 'home':
    return html`<home-view></home-view>`;
  case 'search':
    return html`<search-view></search-view>`;
  case 'results':
    return html`<results-view></results-view>`;
  default:
    return ``;
 }
}

und binden diese noch in unserer render() Methode entsprechend ein:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
             ></mwc-icon-button>
             <div slot="title">Title</div>
           </mwc-top-app-bar>
-          <div>
-            <p>Main Content!</p>
-          </div>
+          <main>
+            ${this._renderCurrentView()}
+          </main>
         </div>
       </mwc-drawer>
     `;

Obwohl wir nun den Inhalt Home… definiert im HomeView.js sehen sollten, funktioniert dies noch nicht. Wir verwenden zwar den richtigen Tag <home-view>. Der Browser kennt diesen Tag allerdings noch nicht, weil wir im AmenityFinder die Komponente noch nicht importiert haben. Das holen wir mit den folgenden vier Zeilen noch nach:

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

+import './views/HomeView.js';
+import './views/ResultsView.js';
+import './views/SearchView.js';
+
 export class AmenityFinder extends LitElement {
   static get properties() {
     return {

Nun sollten wir den Inhalt von <home-view> in unserer Applikation sehen:

Applikation mit Inhalt von HomeView

Das Wechseln der Views über Anpassungen im Programmcode sollte nun auch funktionieren. Setzen wir den Wert von currentView zum Beispiel auf results, sollte der Inhalt von <results-view> angezeigt werden usw.

Damit der Wechsel der Views auch im UI funktioniert, binden wir in AmenityFinder.js für die mwc-list-item Elemente die Methode _navigateTo() als click Event Handler:

<mwc-list-item @click="${() => this._navigateTo('home')}">Home</mwc-list-item>
<mwc-list-item @click="${() => this._navigateTo('search')}">Search</mwc-list-item>
<mwc-list-item @click="${() => this._navigateTo('results')}">Results</mwc-list-item>

Und in der _navigateTo Methode setzen wir den Wert des currentView Properties und schliessen die Sidebar:

_navigateTo(view) {
  this.currentView = view;
  this.showSidebar = false;
}

Routing einbauen (optional)

SPA Routing im Development-Server aktivieren

Damit unsere Routes im @web/dev-server auch bei einem Page-Load einer Unterseite funktionieren, müssen wir das appIndex property in der Konfiguration web-dev-server.config.mjs auf unsere Index-Datei setzen:

--- web-dev-server.config.mjs
+++ web-dev-server.config.mjs
   /** Set appIndex to enable SPA routing */
-  // appIndex: 'demo/index.html',
+  appIndex: 'index.html',

   plugins: [
     /** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */

WICHTIG: Falls der Server am Laufen ist, muss er für das Einlesen der angepassten Konfiguration neu gestartet werden.

Routing Library auswählen

Damit wir die Logik wie das Parsen der URL, das Interpretieren der Parameter usw. nicht selber schreiben müssen, wählen wir dafür eine Library aus. Für unser Beispiel eignet sich Page.js sehr gut:

Tiny ~1200 byte Express-inspired client-side router.

Ihr könnt selbstverständlich auch eine andere Library eurer Wahl verwenden. Als Beispiel: eine mächtigere Alternative, die allerdings auch etwas mehr Komplexität mit sich bringt, ist der Vaadin Router.

Bei der Auswahl einer Library sollte stets darauf geachtet werden, wie gross resp. «schwer» sie ist. Jede Abhängigkeit, die wir in unserer Applikation einbauen, hat Auswirkungen auf die Performance und auch Ladezeit. Dies fällt einem bei schnellen Internetleitungen und grossen Desktop-Devices nicht unbedingt auf, werden die Devices und Internetanschlüsse aber kleiner und mobiler, gewinnt die Leichtgewichtigkeit von Libraries an Bedeutung. Mit Page.js sind wir mit ~6 kB (minified) resp. ~2 kB (minified + gzipped) sehr gut unterwegs.

Data-driven Routing

Das Routing in unserer Applikation soll «Data-driven» sein. Das heisst, Daten resp. der Zustand unserer Applikation definiert, was angezeigt wird. Der Router ist zuständig, um die URLs zu parsen und die Daten für die Applikation daraus abzuleiten. So stellen wir sicher, dass unsere Logik und die Komponenten vom Router entkoppelt bleiben.

Mit unserem currentView Property und der dazugehörigen Render-Methode haben wir einen Grundstein für das «Data-driven» Routing gelegt. Nun bauen wir noch Page.js ein um die Verbindung zur URL im Browser herzustellen. Zuerst installieren wir die Abhängigkeit mit:

$ npm install page@^1.11.6

und importieren die Library in unsere Applikation:

import page from 'page';

Danach können wir, zentral an einem Ort, unsere Routes definieren. Wir erstellen dafür eine neue Methode _initializeRoutes() und initialisieren die Routes gem. der Dokumentation von Page.js wie folgt:

_initializeRoutes() {
  page('/', () => {
    this.currentView = 'home';
  });
  page('/results', () => {
    this.currentView = 'results';
  });
  page('/search', () => {
    this.currentView = 'search';
  });
  page();
}

Jedes Mal, wenn im Browser die URL z. B. /results ist, ruft Page.js den definierten Callback auf. In diesem setzen wir unsere Daten (den Wert des currentView Property) auf den jeweils richtigen Wert für die dazugehöre View. Damit unsere erste, rudimentäre Version unseres Routing funktioniert, müssen wir die neue Methode _initializeRoutes() noch aufrufen. Ein guter Ort dafür ist der Konstruktor:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
     super();
     this.showSidebar = false;
     this.currentView = 'home';
+
+    this._initializeRoutes();
   }

   render() {

Wir können unsere URLs danach bereits testen. Wenn wir im Browser auf http://localhost:8000/search gehen, sollten wir den Inhalt unserer <search-view> Komponente sehen, die URL http://localhost:8000/results sollte den Inhalt von <results-view> anzeigen usw.

Zum Schluss wollen wir noch die Navigation in der Sidebar mit dem Routing verbinden. Bei jedem Klick auf einen Navigationspunkt soll die URL wechseln und die entsprechend richtige View angezeigt werden. Dafür erstellen wir eine neue Methode _navigateToUrl(url). Sie nimmt einen Parameter entgegen, die URL zu welcher navigiert werden soll, und führt über die Page.js-Library das Navigieren zur entsprechenden URL aus:

_navigateToUrl(url) {
  page(url);
}

Diese Methode führen wir nun bei jedem Klick auf einen Navigationspunkt mit dem entsprechenden Wert für den url Parameter aus:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
       >
         <span slot="title">Navigation</span>
         <mwc-list>
-          <mwc-list-item>Home</mwc-list-item>
-          <mwc-list-item>Search</mwc-list-item>
-          <mwc-list-item>Results</mwc-list-item>
+          <mwc-list-item @click="${() => this._navigateToUrl('/')}">Home</mwc-list-item>
+          <mwc-list-item @click="${() => this._navigateToUrl('/search')}">Search</mwc-list-item>
+          <mwc-list-item @click="${() => this._navigateToUrl('/results')}">Results</mwc-list-item>
         </mwc-list>
         <div slot="appContent">
           <mwc-top-app-bar>

Danach sollte die Navigation über das Menu in der Sidebar bereits funktionieren. Falls euer <mwc-drawer> das Attribut type="modal" hat, ist noch etwas unschön, dass die Sidebar beim Navigieren nicht zugeklappt wird. Dies können wir mit ein paar einfachen Anpassungen noch ergänzen.

Wir führen eine neue Methode _closeSidebar() ein. Diese setzt unser showSidebar Property auf false und wir können sie in der _navigateToUrl Methode noch aufrufen. Wiederverwenden können wir die neue Methode übrigens auch beim bereits gebundenem @MDCDrawer:closed Event. Das ganze sieht dann wie folgt aus:

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

   render() {
     return html`
-      <mwc-drawer
-        hasHeader
-        type="modal"
-        .open="${this.showSidebar}"
-        @MDCDrawer:closed="${() => {
-          this.showSidebar = false;
-        }}"
-      >
+      <mwc-drawer hasHeader type="modal" .open="${this.showSidebar}" @MDCDrawer:closed="${this._closeSidebar}">
         <span slot="title">Navigation</span>
         <mwc-list>
           <mwc-list-item @click="${() => this._navigateToUrl('/')}">Home</mwc-list-item>

     page();
   }

   _navigateToUrl(url) {
     page(url);
+    this._closeSidebar();
+  }
+
+  _closeSidebar() {
+    this.showSidebar = false;
   }
 }

Unsere finale Version sieht nun etwa wie folgt aus:

Routing über Navigation

Deployment für Routing anpassen (optional)

Falls wir unsere Applikation nach wie vor über Surge deployen, stellen wir fest, dass Direktlinks zu unseren im AmenityFinder definierten Seiten (z. B. https://amenity-finder.surge.sh/search) nicht funktionieren.

Dies liegt daran, dass wir unsere Routes in unserer Single-Page-Applikation definiert haben. Diese wird im Moment nur unter / (der Server liefert hier automatisch das index.html aus) ausgeliefert. Wir müssen dem Server beibringen, dass alle Pfade nach dem Host jeweils an unsere Applikation, konkret das index.html, umgeschrieben werden sollen. In der Dokumentation von Surge ist dies im Abschnitt “Adding a 200 page for client-side routing” zu finden.

Surge leitet also alle Requests auf ein File 200.html weiter, sofern eine solche Datei existiert. Wir erstellen dafür bei jedem Build eine Kopie unseres index.html und speichern diese als 200.html ab. Wir können diesen Schritt mit dem folgenden postbuild npm-Skript einbauen:

"postbuild": "cp dist/index.html dist/200.html"

Layout optimieren (optional)

Der Hauptinhalt jeder Seite klebt im Moment noch etwas am Rand. Mit einer minimalen CSS Ergänzung können wir diese kleine Unschönheit einfach beheben:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
       :host {
         min-height: 100vh;
       }
+
+      main {
+        padding: var(--amenity-container-padding, 1rem);
+      }
     `;
   }

Wir verwenden dabei das --amenity-container-padding CSS Custom Property mit einem Default-Wert von 1rem. Wir haben so eine Variable, die wir ev. später bei weiteren Containern für das Padding wiederverwenden können. Gleichzeitig erlauben wir das Theming unserer Applikation «von aussen», ausserhalb unseres Shadow DOM.

Anwendungsbeispiel

Als Beispiel können wir (oder jemand, der unsere Komponenten oder Applikation einbindet) im index.html das Custom Property auf einen anderen Wert setzen. Das neue Padding wird über Shadow DOM Grenzen hinweg durch die ganze Applikation propagiert:

--- index.html
+++ index.html
        * @see https://material.io/develop/web/docs/theming
        */
       --mdc-theme-primary: #004996;
+
+      --amenity-container-padding: 4rem;
     }
   </style>
   <title>amenity-finder</title>

Fantastisch!

Du hast den dritten Teil der Amenity Finder Serie erfolgreich abgeschlossen. In diesem Teil haben wir die Amenity Finder Applikation in einzelne Seiten (Views) unterteilt und das Routing implementiert. Du hast gelernt, wie du mithilfe des Routers die einzelnen Seiten über eindeutige URLs aufrufen kannst. Jetzt können Benutzer nahtlos zwischen den verschiedenen Seiten der Applikation navigieren. Im vierten Teil werden wir die Suchseite inklusive Parametrisierung zum Absetzen der Suche aufbauen.