Bewertung der Navigation

Eine SPA, also Single Page Application, basiert auf lediglich einer HTML-Seite. Damit innerhalb dieser einen Seite verschiedene Szenarien abgebildet werden können, müssen unterschiedliche Ansichten simuliert werden. Dies geschieht, indem Komponenten je nach Zustand der Anwendung ein- oder ausgeblendet werden. Für genau diese Aufgabe liefert Angular den Router mit.

In diesem Tutorial erweitern wir das bestehende Beispiel so, dass mit Hilfe des Angular Routers unterschiedliche Ansichten angezeigt werden. Man spricht hier von Routen, die sich über Einträge in einem Menü gezielt aufrufen lassen.

Den vollständigen Quellcode hierzu gibt es wie gewohnt auf GitHub.

Grundkonzepte

Möchte eine SPA mehrere Seiten simulieren, genügt das bloße Ein- und Ausblenden von Komponenten nicht. Damit der Back-Button des Browsers wie erwartet reagiert, muss die Änderung des Anwendungszustands in der URL ablesbar sein. Auch für Lesezeichen oder direkte Verweise, die gezielt auf eine bestimmte Ansicht innerhalb der SPA abzielen, ist dies unerlässlich. Diese Anforderung wird als Deep Linking bezeichnet – und der Router automatisiert genau diesen Vorgang, indem er jeder definierten Route eine eigene URL zuweist.

Der mitgelieferte Angular Router sieht vor, dass die SPA neben Bereichen wie Menüs oder Fußzeilen zusätzlich eine einzelne Einfügemarke – einen Platzhalter – besitzt:

SPA

Die Festlegung, welche Komponente in diesem Platzhalter angezeigt wird, erfolgt über einen zusätzlichen Pfad, den der Benutzer an die URL anfügt. Dieser Pfad greift auf einen Eintrag in der Konfiguration zu, der unter anderem die gewünschte Komponente referenziert. Man sagt auch, der Router aktiviert die angesprochene Komponente:

Aktivieren

Hier wurde der URL der SPA der Pfad /flug-suchen angehängt. Dies veranlasst den Router, die damit verknüpfte FlightSearchComponent zu aktivieren.

Vorbereitung der Komponenten

Um die Funktionsweise des Routers zu demonstrieren, passen wir die Menülinks auf der linken Seite an und versehen sie mit einer konkreten Funktion:

Der

In dieser Anwendung befindet sich der Platzhalter rechts neben dem Seitenmenü. Abhängig vom aktuellen Zustand soll er eine der folgenden Komponenten anzeigen:

  • HomeComponent: Zeigt eine Willkommensnachricht.
  • FlightSearchComponent: Die uns bereits bekannte Komponente für die Flugsuche.
  • PassengerSearchComponent: Diese Komponente dient zur Suche nach Passagieren. Vorerst existiert sie nur als Platzhalter, die ein weiteres Ziel für die Navigation darstellt.
  • FlightEditComponent: Mit dieser Komponente können Flüge bearbeitet werden. Auch sie ist zunächst nur ein Platzhalter. Im Gegensatz zur PassengerSearchComponent erwartet sie jedoch einen Parameter, der über die Route übergeben wird.
  • AboutComponent: Zeigt allgemeine Informationen zur Anwendung.
  • NotFoundComponent: Wird präsentiert, sobald keine passende Route für einen Pfad existiert.

Diese Komponenten – mit Ausnahme der bereits vorhandenen FlightSearchComponent – lassen sich über die gewohnten Befehle der Angular CLI erzeugen:

ng generate component home
ng generate component passenger-search
ng generate component flight-edit
ng generate component about
ng generate component not-found

Beachten Sie, dass die zweite Anweisung die PassengerSearchComponent innerhalb des Ordners flight-booking generiert. Dort ist das FlightBookingModule beheimatet, bei dem die Komponente automatisch registriert wird. Die übrigen Komponenten erzeugt die CLI im Ordner app und trägt sie in die dortige AppComponent ein.

Die Komponenten lassen sich alternativ zur Kommandozeile auch über das bereits angesprochene Plug-in Angular Schematics in Visual Studio Code generieren.

Vergewissern Sie sich nach der Generierung, dass die Angular CLI die neuen Komponenten vollständig beim AppModule angemeldet hat.

Damit die Benutzer einen angemessenen Empfang erhalten, haben wir das Template der HomeComponent entsprechend angepasst:

<!-- src/app/home/home.component.html -->
<h1>Welcome!</h1>

Aufbau der Routing-Konfiguration

Der Router benötigt Klarheit darüber, welche Komponente zu welchem Zeitpunkt zu aktivieren ist. Dafür legen wir im Verzeichnis src/app die Datei app.routes.ts mit der Routenkonfiguration für die Komponenten im AppModule an.

Die Routing-Konfiguration ist eine herkömmliche TypeScript-Datei, die Sie direkt in Visual Studio Code anlegen können (Rechtsklick auf den Ordner app | New File). Ihr Inhalt ist eine Array-Konstante mit Objekten vom Typ Route. Diese bilden in erster Linie Pfade auf Komponenten ab:

// src/app/app.routes.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { FlightSearchComponent } from './flight-search/flight-search.component';
import { PassengerSearchComponent } 
    from './passenger-search/passenger-search.component';
import { FlightEditComponent } from './flight-edit/flight-edit.component';
import { AboutComponent } from './about/about.component';
import { NotFoundComponent } from './not-found/not-found.component';

export const APP_ROUTES: Routes = [
    {
        // Standardroute: Umleitung auf '/home'
        path: '',
        redirectTo: 'home',
        pathMatch: 'full'
    },
    {
        path: 'home',
        component: HomeComponent
    },
    {
        path: 'flight-search',
        component: FlightSearchComponent
    },
    {
        path: 'flight-edit/:id',
        component: FlightEditComponent
    },    
    {
        path: 'passenger-search',
        component: PassengerSearchComponent
    },
    {
        path: 'about',
        component: AboutComponent
    },
    {
        path: '**',
        component: NotFoundComponent
    }
];

Besonderes Augenmerk liegt hier auf der ersten Route: Sie besitzt keinen Pfad und fungiert deshalb als Standardroute. Angular aktiviert sie für den Fall, dass kein Pfad an die URL des SPA angehängt wurde. Ein typisches Beispiel dafür ist http://localhost:4200. Über die Angabe redirectTo wird von der Standardroute aus auf die darunter definierte home-Route umgeleitet.

Ein Stolperstein ergibt sich jedoch: Standardmäßig wertet Angular den Pfad aus der Konfiguration auf seine Präfixeigenschaft zum URL-Pfad aus. Da jeder Leerstring in JavaScript als Präfix jeden anderen Strings gilt, würde die Standardroute ohne Pfadangabe für jede Adresse herangezogen.

Aus diesem Grund ist die Eigenschaft pathMatch: full erforderlich. Mit ihr vergleicht Angular allein den vollständigen Pfad der Konfiguration mit dem vollständigen Pfad in der URL.

Möglicherweise ist Ihnen auch die Endung :id im Pfad der Komponente FlightEditComponent aufgefallen. Dies entspricht einem Platzhalter mit dem Namen id. Den konkreten Wert für diesen Platzhalter können wir später in der FlightEditComponent abrufen.

Der Eintrag path: ** als letztes Element bewirkt, dass alle nicht weiter definierten Pfade an die NotFoundComponent weitergeleitet werden. Dies stellt ein letztes Auffangnetz für ungültige Adressen dar.

Damit Angular die Konfiguration übernimmt, muss diese gemeinsam mit dem RouterModule im AppModule importiert werden:

// src/app/app.module.ts

[...]
// Diese beiden Importe einfügen:
import { RouterModule } from '@angular/router';
import { APP_ROUTES } from './app.routes';

@NgModule({
    imports: [
        // Diese Zeilen hinzufügen:
        RouterModule.forRoot(APP_ROUTES),
        [...]
    ],
    declarations: [
        [...]
    ],
    providers: [],
    bootstrap: [
        AppComponent
    ]
})
export class AppModule { }

Bitte achten Sie darauf, dass wir hier RouterModule.forRoot aufrufen. Da diese Methode Services für die gesamte Anwendung registriert, darf sie ausschließlich im AppModule verwendet werden.

Die Angular-Schulungen, die Sie weiterbringen

Von der Angular-Praxis-Schulung für Einsteiger über Fortgeschrittenen-Workshops bis hin zum Deep Dive für erfahrene Nutzer – in unseren Angular Schulungen erfahren die Teilnehmer alles, was man für die erfolgreiche Umsetzung von Angular-Projekten wissen muss.

Platzhalter in der AppComponent verankern

Die AppComponent verweist hier nicht mehr direkt auf eine konkrete Komponente, sondern auf einen Platzhalter. Dieser Platzhalter wird dem Router durch die Verwendung des router-outlet-Elements bereitgestellt:

<!-- src/app/app.component.html -->

<div class="wrapper">

    <div class="sidebar" data-color="white" data-active-color="danger">
        <app-sidebar-cmp></app-sidebar-cmp>
    </div>

    <div class="main-panel">
        <app-navbar-cmp></app-navbar-cmp>

        <div class="content">

            <!-- Diese Zeile entfernen: -->
            <!-- <app-flight-search></app-flight-search> -->

            <!-- Diese Ziele hinzufügen: -->
            <router-outlet></router-outlet>

        </div>
    </div>

</div>

Es fehlen also nur noch die Hyperlinks, die jeweils eine bestimmte Route aktivieren. Dafür passen wir die bereits generierte SidebarComponent an:

<!-- src/app/sidebar/sidebar.component.html -->

[...]

<!-- Diese Einträge um routerLink -->
<!-- und routerLinkActive erweitern: -->
<li routerLinkActive="active">
    <a routerLink="home">
        <p>Home</p>
    </a>
</li>

<li routerLinkActive="active">
    <a routerLink="flight-search" >
        <p>Flights</p>
    </a>
</li>

<li routerLinkActive="active">
    <a routerLink="passenger-search">
        <p>Passengers</p>
    </a>
</li>

<!-- Diesen Eintrag ergänzen: -->
<li routerLinkActive="active">
    <a routerLink="about">
        <p>About</p>
    </a>
</li>

Die Direktive routerLink stammt aus dem RouterModule und verweist auf die einzelnen Pfade unserer Routenkonfiguration. Die Direktive routerLinkActive wiederum referenziert den Namen einer CSS-Klasse, die für die Hervorhebung des gerade aktiven Menüpunktes zuständig ist. Sie kommt dann zum Einsatz, wenn das betreffende Element einen aktiven routerLink besitzt.

Beim Start der Anwendung sollten die Menüpunkte des Seitenmenüs nun mit den konfigurierten Routen verknüpft sein:

Routing

Die aktive Route lässt sich in der Adresszeile des Browsers anhand der URL ablesen.

Auslesen von Routing-Parametern

Um den für flight-edit hinterlegten Routing-Parameter zu erfassen, lässt sich die FlightEditComponent den Service ActivatedRoute injizieren:

// src/app/flight-booking/flight-edit/flight-edit.component.ts

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
    selector: 'app-flight-edit',
    templateUrl: './flight-edit.component.html',
    styleUrls: ['./flight-edit.component.scss']
})
export class FlightEditComponent implements OnInit {

    id = 0;
    showDetails = false;

    constructor(private route: ActivatedRoute) { }

    ngOnInit(): void {
        this.route.params.subscribe(p => {
            this.id = p.id;
            this.showDetails = p.showDetails;
        });
    }

}

Die ActivatedRoute umfasst neben anderen Metadaten der gerade verwendeten Route auch das Observable params. Über dieses erhält man Zugriff auf sämtliche Routing-Parameter, die als Objekt bzw. Dictionary ausgeliefert werden.

Der Parameter id entspricht dem Parameter, den wir in der Routenkonfiguration definiert haben. Der Parameter showDetails hingegen wurde dort nicht angelegt. Deshalb nimmt Angular hier an, dass er als direktes Name/Wert-Paar an den Pfad angehängt wird:

/flight-edit/17;showDetails=true

Im letzteren Fall spricht die URL-Spezifikation von Matrix-Parametern. Diese werden mit Semikolons abgetrennt und beziehen sich auf das letzte URL-Segment – in unserem Fall also auf die damit verknüpfte Komponente. Der bekanntere Query-String (beginnend mit einem Fragezeichen) gilt im Gegensatz dazu für die gesamte URL.

Im Template der Komponente werden die Werte dieser Parameter schließlich abgebildet:

<h1>Flight Edit</h1>

<p>
    Id: {{id}}
</p>
<p>
    ShowDetail: {{id}}
</p>

An dieser Stelle begnügen wir uns damit, die Parameter nur auszugeben. Die bereits bekannten Kenntnisse aus diesem Tutorial könnten später genutzt werden, um den mit der jeweiligen id verknüpften Flug zu laden und über ein Formular zu bearbeiten.

Verlinken parametrisierter Routen

Die Direktive routerLink akzeptiert nicht nur einzelne URL-Segmente, sondern auch ein Array mit allen Bestandteilen des Pfads. Dies bringt uns zu folgendem Beispiel, in dem wir das Template der FlightCardComponent um einen routerLink ergänzen, der auf die zuvor eingeführte FlightEditComponent zeigt:

<!-- src/app/flight-booking/flight-card/flight-card.component.html -->

[...]
<p>
    <button class="btn btn-default" 
            *ngIf="!selected" 
            (click)="select()">Select</button>

    <button class="btn btn-default" 
            *ngIf="selected" 
            (click)="deselect()">Remove</button>

    <!-- Diesen Link einfügen: -->
    <a class="btn btn-default" 
       [routerLink]="['../flight-edit', item?.id, {showDetails:false}]">
       Edit
    </a>
</p>
[...]

Jedes Element im Array entspricht hierbei einem URL-Segment. Die Direktive routerLink führt die URL-Erstellung durch, kodiert einzelne Segmente und verbindet sie passend miteinander. Die Eigenschaften von Objekten werden zu Matrixparametern. Für das angeführte Beispiel ergibt sich damit – angenommen item.id hat den Wert 3 – folgender Pfad:

../flight-edit/3;showDetails=true

Der Zusatz ../ ist an dieser Stelle nötig, da wir davon ausgehen, dass die FlightCardComponent über die FlightSearchComponent erreicht wird. Deren Route ist in der Konfiguration als Schwester von flight-edit angelegt.

Programmatisches Navigieren

Neben Hyperlinks kann die Änderung der Route auch direkt aus dem Code heraus angestoßen werden. Hierfür injizieren Sie den Service Router:

[…]
import { Router } from '@angular/router';

@Component({ […] })
export class AppComponent {
    constructor(private router: Router) { }

    goHome(): void {
        this.router.navigate(['/home']);
    }
}

Die Methode navigate nimmt den Zielpfad als Array entgegen. Jeder Eintrag entspricht – analog zur routerLink-Verwendung – einem Segment der URL. Die Segmente werden kodiert und zusammengesetzt, um die richtige Route zu finden. Der Aufruf:

this.router.navigate(['/flight-edit', id]);

aktiviert demnach die Route /flight-edit/17, sofern die Variable id den Wert 17 enthält.

Bonus: Routing and Modules

Up to this point, we have deliberately kept things simple by working with a single module, the AppModule. To improve the maintainability of the application, a sensible next step is to isolate each feature into its own module. Typically, each of these modules gets its own folder as well as its own route configuration.

In our scenario, creating a FlightBookingModule is a logical choice:

Feature

All components belonging to the flight booking feature were moved into the new flight-booking directory. If you try this yourself, double-check that every import statement still points to the correct file path after the move.

Since this restructuring alters the layout of the project, we have isolated the source code for this bonus section into a separate branch of our sample repository.

Inside the flight-booking folder, you will also find the route configuration, which is limited to the components of this module:

// src/app/flight-booking/flight-booking.routes.ts

import { Routes } 
    from '@angular/router';
import { FlightEditComponent } 
    from './flight-edit/flight-edit.component';
import { FlightSearchComponent } 
    from './flight-search/flight-search.component';
import { PassengerSearchComponent } 
    from './passenger-search/passenger-search.component';

export const FLIGHT_BOOKING_ROUTES: Routes = [
    {
        path: 'flight-search',
        component: FlightSearchComponent
    },
    {
        path: 'passenger-search',
        component: PassengerSearchComponent
    },
    {
        path: 'flight-edit/:id',
        component: FlightEditComponent
    },
];

Just like the AppModule, the FlightBookingModule is a class decorated with metadata:

// src/app/flight-booking/flight-booking.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FlightSearchComponent } 
    from './flight-search/flight-search.component';
import { FlightCardComponent } 
    from './flight-card/flight-card.component';
import { PassengerSearchComponent } 
    from './passenger-search/passenger-search.component';
import { FlightEditComponent } 
    from './flight-edit/flight-edit.component';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';

import { FLIGHT_BOOKING_ROUTES } from './flight-booking.routes';

@NgModule({
  imports: [
    CommonModule,

    // Important: Routes are referenced with **forChild**
    RouterModule.forChild(FLIGHT_BOOKING_ROUTES),

    // Dont't forget this if you want to work with Forms
    FormsModule,
  ],
  declarations: [
    FlightSearchComponent,
    FlightCardComponent,
    PassengerSearchComponent,
    FlightEditComponent,
  ],
})
export class FlightBookingModule { }

A key detail here is that routes for feature modules are registered using RouterModule.forChild. Only the root module, our AppModule, calls RouterModule.forRoot. This is a standard practice within the Angular ecosystem. It guarantees that a library such as the router sets up its application-wide services exactly once through forRoot. Every subsequent invocation of forChild only registers the additional structures needed for using the router within that particular feature module.

The example also imports the CommonModule. This module provides structural directives such as *ngIf or *ngFor and the standard pipes like date or json. Since these constructs are required in most templates, it is customary for nearly every feature module to include this import.

You may have noticed that the AppModule never needed to import the CommonModule explicitly, yet *ngIf, *ngFor, and the mentioned pipes were available throughout. This is because the BrowserModule, which we have imported in the AppModule from the very beginning, re-exports all the content of the CommonModule.

To make Angular aware of the new FlightBookingModule, we need to import it into the AppModule as well:

// src/app/app.module.ts

[...]

// Add this import:
import { FlightBookingModule } 
    from './flight-booking/flight-booking.module';

@NgModule({
   imports: [
      RouterModule.forRoot(APP_ROUTES),
      HttpClientModule,
      BrowserModule,
      FormsModule,

      // Add import for Feature-Module:
      FlightBookingModule,
   ],
   declarations: [
      AppComponent,
      SidebarComponent,
      NavbarComponent,
      HomeComponent,
      AboutComponent,
      NotFoundComponent
   ],
   providers: [],
   bootstrap: [
      AppComponent
   ]
})
export class AppModule { }

Summary

The router provided by Angular lets you emulate distinct pages within a Single Page Application (SPA). To take advantage of it, you map paths to components. When the current URL matches one of these paths, the router activates the corresponding component inside a designated placeholder on the page. Furthermore, you are able to pass data to the activated component through parameters included in the URL.