Sie sind hier: Weblog

Den neuen Component-Router mit AngularJS 1.x nutzen

Foto ,
24.02.2016 01:23:00

Den für Angular 2 entwickelten Component Router gibt es auch in einer Version für AngularJS 1.x. Er bietet Möglichkeiten, für die Entwicklungs-Teams in der Vergangenheit auf andere Lösungen, allen voran UI-Router, ausweichen mussten. Daneben lässt er sich auch als Teil einer Migrations-Strategie für den Wechsel von AngularJS 1.x auf Angular 2 einsetzen. Der erste Schritt einer Migrations-Strategie besteht nämlich in der Regel im Vorbereiten des bestehenden Codes.

In diesem Beitrag zeige ich anhand eines Beispiels, wie der Component Router in AngularJS 1.x genutzt werden kann. Das komplette Beispiel findet man unter [1].

Bibliothek beziehen und einbinden

Der Component Router für AngularJS 1.x steht über npm zur Verfügung:

npm install ngcomponentrouter --save

Um den heruntergeladenen Router zu nutzen, ist er in der SPA zu referenzieren und in das Angular-Modul der Wahl einzubinden:

import node_modules/ngcomponentrouter/angular_1_router;
[...]
var app = angular.module(app, [ngComponentRouter]);
[...]

Top-Level-Komponente mit Routing-Konfiguration einrichten

Wie der Name schon vermuten lässt, adressiert der Component Router Komponenten. Dabei handelt es sich bei AngularJS 1.x um Direktiven, die bestimmten Konventionen folgen. Diese kann eine Anwendung über die bekannte Methhode module.directive registrieren. Ab Angular 1.5 steht alternativ dazu die Methode module.component, welche die nötigen Konvetionen berücksichtigt, zur Verfügung. Im Wesentlichen gruppiert sie ein Template und einen dazugehörigen Controller. Daneben definiert sie weitere Aspekte, wie Bindings oder controllerAs. Das nachfolgende Beispiel demonstriert den Einsatz von module.component zum Bereitstellen einer Top-Level-Component, welche die gesamte Anwendung repräsentiert:

class AppController {
}

app.component(app, { 
    controller: AppController,
    controllerAs: app,
    templateUrl: "app.html",
    $routeConfig: [
        { path: /, component: home, name: Home, useAsDefault: true },
        { path: /bookFlight/..., component: bookFlight, name: BookFlight }
    ]
});

app.value($routerRootComponent, app);

Die Eigenschaft $routeConfig bietet eine Konfiguration für den Component Router. Diese bildet Pfade auf Komponenten ab. Diese Komponenten sind ebenfalls in Form von Direkten, idealerweise mit module.component einzurichten:

app.component(home,  { 
    controller: HomeController,
    controllerAs: vm,
    templateUrl: components/home/home.html
});

Die Komponente app spendiert jeder Route einen internen Namen und zeichnet Home mit useAsDefault als standardmäßig zu nutzende Route aus. Somit zieht der Router diese Route heran, wenn er keine Route findet, die zur aktuellen Url passt.

Der Pfad der Route BookFlight endet mit drei Punkten. Dies weist den Router darauf hin, dass BookFlight einen weiteren Platzhalter aufweist. Solche Komponenten haben auch zum Verwalten dieses Platzhalters einen eigenen Router, der sich Child-Router nennt. Die Konfiguration dafür findet sich innerhalb dieser Komponente. Somit gehören große und unübersichtliche Routing-Konfigurationen der Vergangenheit an. Dies erleichtert auch die Wiederverwendung einzelner Komponenten(-Gruppen).

Das Beispiel zeichnet die Komponente app auch als Top-Level-Component aus, indem es ihren Namen an die Modul-Variable $routerRootComponent zuweist. Das Template definiert mit der Direktive ng-outlet jenen Platzhalter, in dem der Router die jeweils aktive Komponente platziert. Der Einsatz von ng-link erzeugt hingegen einen Verweis, der auf eine der beiden definierten Routen verweist. Der Slash am Beginn von Home und BookFlight weist den Router darauf hin, dass Routen auf der obersten Hierarchieebene gemeint sind:

[...]
<ul class="nav navbar-nav">
    <li><a ng-link="[/Home]">Home</a></li>
    <li><a ng-link="[/BookFlight]">Book Flight</a></li>
</ul>
[...]
<div>
    <div ng-outlet></div>
</div>

Um die Top-Level-Component zu nutzen, ist in der SPA lediglich ein Element, das darauf verweist, zu platzieren:

<app></app>

Die Registrierung eines Controllers ist somit nicht notwendig. Es genügt die Anwendung mittels ng-app oder via angular.bootstrap zu starten.

Hierarchisches Routing mit Child-Router

Komponenten, die der Router im Platzhalter aktiviert, können einen weiteren Platzhalter aufweisen. Auf diese Weise lassen sich verschachtelte bzw. hierarchisch organisierte Views gestalten. Wie zuvor erwählt, handelt es sich bei BookFlight um ein Beispiel dafür:

app.component(bookFlight,  { 
    controller: BookFlightController,
    controllerAs: bookFlight,
    templateUrl: components/book-flight/book-flight.html,
    $routeConfig: [
        { path: /, component: passenger, name: Passenger },
        { path: /flight, component: flight, name: Flight, useAsDefault: true },
        { path: /booking, component: booking, name: Booking },
        { path: /passenger/:id, component: passengerEdit, name: PassengerEdit }
    ]
}); 

Bei :id im Pfad der Route PassengerEdit handelt es sich um einen Platzhalter. Ein Abschnitt weiter unten geht darauf genauer ein.

Zur Verwaltung ihres eigenen Platzhalters erhält bookFlight auch einen eigenen sogenannten Child-Router. Dessen Konfiguration findet sich in der Variable $routeConfig im Rahmen der Komponenten-Definition.

Das Template dieser Komponente definiert den Platzhalter abermals durch router-outlet:

<div class="col-md-3">

    <h4>Steps</h4>

    <!-- Steps -->
    <div>
        <ul class="nav nav-pills nav-stacked">
            <li><a ng-link="[./Passenger]">Passenger</a></li>
            <li><a ng-link="[./Flight]">Flight</a></li>
        </ul>
    </div>
    <!-- /Steps -->

</div>

[...]

<div class="col-md-9">
    <div ng-outlet></div>
</div>

Wie auch das Template der Top-Level-Component richtet dieses Template Links mittels ng-link ein. Diese erhalten den Präfix ./ um - wie im Dateisystem - anzudeuten, dass diese Routen der aktuellen direkt untergeordnet sind. Alternativ dazu könnten auch die Angaben /BookFlight/Passenger sowie /BookFlight/Flight zum Einsatz kommen, zumal diese Routen unterhalb von BookFlight angesiedelt sind.

Life-Cycle-Hooks nutzen

Der Component Router unterstützt das auch in Angular 2 zu findende Konzept der Life-Cycle-Hooks. Dabei handelt es sich um optionale Methoden, die Angular beim Auftreten bestimmter Ereignisse ausführt. Durch Bereitstellen solcher Methoden kann eine Anwendung auf diese Ereignisse reagieren.

Die meisten Life-Cycle-Hooks sind direkt im Komponenten-Controller einzurichten. Ein Beispiel dafür findet sich in der nachfolgend gezeigten Komponente PassengerEditController. Sie implementiert die Hooks $routerOnActivate, $routerOnDectivate und $routerCanDeactivate. Die ersten beiden stößt der Router beim Aktivieren bzw. Deaktivieren der Komponente an. Mit Aktivieren ist das Platzieren der Komponente im Platzhalter gemeint; mit Deaktivieren das Entfernen aus dem Platzhalter. Letzteres ist der Fall, wenn der Router eine andere Komponente aktiviert. Eine Anwendung kann diese Hooks für Vorbereitungen oder Aufräumarbeiten nutzen.

Durch Implementieren von $routerCanDeactivate kann die Anwendung ein Veto gegen das Verlassen einer Route einlegen. In diesem Fall retourniert sie false. Mit dem Rückgabewert true gibt sie hingegen an, dass sie das Verlassen der Route billigt und mit einem Promise schiebt sie die Entscheidung auf, bis sie ihn später mit true oder false auflöst.

export class PassengerEditController {

    [...]


    $routerOnActivate() {
        [...]
        this.$log.info("$routerOnActivate");    
    } 

    $routerOnDeactivate() {
        this.$log.info("deactivate");   
    }

    $routerCanDeactivate() {

        return true;

    }

[...]
}

Der Hook $routerCanDeactivate bietet sich zum Beispiel an, um vor dem Verlassen eines Formulars mit ungespeicherten Daten den Benutzer zu warnen. Ein Beispiel für eine solche Implementierung von $routerCanDeactivate findet sich im nachfolgenden Listing. Es liefert true wenn das aktuelle Formular nicht verändert wurde. Somit legt es in diesem Fall kein Veto ein. Ansonsten retourniert es hingegen einen Promise. Zusätzlich setzt es eine Eigenschaft namens show auf true. Das Veranlasst die View zur Darstellung einer Warnung. Die zum Promise gehörenden Methoden resolve und reject verstaut es zusätzlich in entsprechenden für das Template verfügbaren Variablen.

Der Promise bekommt auch einen ersten Success-Handler, welcher die getroffene Entscheidung (true oder false) entgegennimmt und retourniert. Zusätzlich setzt er show auf false um die Warnmeldung wieder auszublenden.

$routerCanDeactivate() {

    if (!this.$scope.form.$dirty) return true;

    return this.$q((resolve, reject) => { 
        this.exitWarning = {
            show: true,
            reject: reject,
            resolve: resolve
        }
    }).then((result) => { 
        this.exitWarning.show = false;
        return result; 
    });

}

Das dazugehörige Markup aus dem Template findet sich nachfolgend. Es besteht aus einem div, das nur angezeigt wird, wenn show den Wert true aufweist. Es zeigt eine Warnmeldung und zwei Links an. Der Link mit dem Text Ja löst den Promise mit true auf; jener mit Nein hingegen mit false.

<div ng-show="passengerEdit.exitWarning.show" class="alert alert-warning">
    <div>
    You didnt save the record. Do you really want to leave?
    </div>
    <div>
        <a href="javascript:void(0)" ng-click="passengerEdit.exitWarning.resolve(true)" class="btn btn-danger">Ja</a>
        <a href="javascript:void(0)" ng-click="passengerEdit.exitWarning.resolve(false)" class="btn btn-default">Nein</a>

    </div>
</div>

Neben den drei betrachteten Life-Cycle-Hooks existiert noch ein vierter: $canActivate. Dieser gibt der Anwendung die Möglichkeit, ein Veto gegen das Aktivieren einer Komponente einzulegen. Dies kann sich eine Anwendung zu nutze machen, um unautorisierte Benutzer von bestimmten Routen fernzuhalten. In erster Linie dient das natürlich eher der Benutzerfreundlichkeit und nicht der Sicherheit, zumal bei einer SPA der gesamte Quellcode am Client vorliegt.

Da Angular im Falle eines Vetos die Komponente gar nicht instanziieren muss, befindet sich dieser Hook nicht im Komponenten-Controller. Stattdessen ist er beim Einrichten der Komponente mit module.component anzuführen:

app.component(passengerEdit, { 
    controller: PassengerEditController,
    controllerAs: passengerEdit,
    //templateUrl: components/passenger-edit/passenger-edit.html,
    template: passengerEditTemplate,
    $canActivate: () => {
        console.debug("$canActivate");
        return true;
    }
});

Bei der Deklaration der Komponente passagierEdit fällt auf, dass sie keine templateUrl aufweist, zumal die entsprechende Zeile auskommentiert ist. Stattdessen erhält sie über die Eigenschaft template einen String mit dem Template. Der Grund dafür liegt in der Tatsache, dass die hier verwendete Version des Component Routers einen Bug aufweist. Dieser Verhindert den Einsatz von Life-Cycle-Hooks, wenn die Anwendung das Template über eine templateUrl festlegt.

Die möglichen Rückgabewerte $canActivate entsprechen denen von $canDeactivate: true, false und Promises sind erlaubt.

All diese Hooks erhalten zwei Parameter: Der erste nennt sich next und beschreibt die Route, auf die der Router wechseln möchte. Der zweite nennt sich prev und gibt hingegen Auskunft über die aktuelle Route.

Routen mit Platzhalter

Beim Betrachten der Konfiguration für die Route PassengerEdit, weiter oben, fällt der mit einem Doppelpunkt eingeleitete Wert :id auf. Dabei handelt es sich um einen Platzhalter, der zur Laufzeit einen konkreten Wert erhält.

Diesen Wert kann die adressierte Komponente im Life-Cycle-Hook $routerOnActivate auslesen:

$routerOnActivate(next) {
    this.load(next.params.id);
    this.$log.info("$routerOnActivate");    
} 

Zum Erzeugen eines Links für eine Route mit Platzhalter nutzt die Anwendung ng-link. Das übergebene Array (Tupel) beinhaltet den Namen der gewünschten Route sowie ein Objekt mit Werten für sämtliche Platzhalter:

<a style="cursor:pointer" ng-link="[../PassengerEdit,{id: p.id}]">Edit</a>

Lazy Loading von Routen

Prinzipiell unterstützt der Component Router auch das verzögerte Laden von Komponenten. Das bedeutet, dass er Komponenten erst bei Bedarf und nicht bereits beim Start der Anwendung lädt. Dies soll die Start-Zeit der Anwendung verbessern. Um Lazy Loading zu nutzen, gibt die Anwendung anstatt der Komponente einen Loader an. Dabei handelt es sich um eine Funktion, die die gewünschte Komponente nachlädt und ihren Namen über einen Promise bekannt gibt. Zur Demonstration gibt das nachfolgende Beispiel den Namen einer zuvor mit module.component eingerichteten Komponente über einen Promise an:

var bookFlightLoader = function() {
    return new Promise((resolve) => { 
        resolve(bookFlight);
    });
}

app.component(app, { 
    controller: AppController,
    controllerAs: app,
    templateUrl: "app.html",
    $routeConfig: [
        { path: /, component: home, name: Home, useAsDefault: true },
        { path: /bookFlight/..., /* component: bookFlight*/ loader: bookFlightLoader, name: BookFlight }
    ]
});

Das funktioniert soweit ganz gut. Das Problem ist nur, dass AngularJS 1.x an sich keine Lösung für das Nachladen von Komponenten bietet. Diese Möglichkeit ist zwar schon länger geplant, wurde jedoch immer wieder verschoben. Ganz unnütz ist die Unterstützung für Loader-Funktionen durch den Component Router trotzdem nicht, denn Community-Erweiterungen, wie das bekannte ocLazyLoad [2] erweitern Angular, sodass es Lazy Loading unterstützt.

Alternative für das Registrieren der Top-Level-Component

Beim Vergleichen der aktuellen Router-Implementierung mit einer früheren, gut dokumentierten Alpha-Version fällt auf, dass diese (noch?) weitere Möglichkeiten zum Deklarieren der Top-Level-Component bietet. Beispielsweise kann sich die Komponente den Router in den Konstruktor injizieren lassen und ihn anschließend zum programmatischen Konfigurieren nutzen. In diesem Fall muss die Komponente auch nicht durch Zuweisung an $routerRootComponent als Top-Level-Component ausgewiesen werden:

class AppController {
    constructor($router) {
        $router.config([
          { path: /, component: home, name: Home, useAsDefault: true },
          { path: /bookFlight/..., component: bookFlight, name: BookFlight }
        ]);
    }
}

app.component(app, { 
    controller: AppController,
    controllerAs: app,
    templateUrl: "app.html"
});

Alternativ dazu kann der Controller der Top-Level-Component auch mit ng-controller innerhalb der SPA aktiviert werden. Dies widerstrebt zwar dem Komponentengedanken des Component Routers, kann jedoch anfangs bei der Migration einer bestehende Anwendung nützlich sein, zumal sowohl UI-Router als auch ngRoute diesen Ansatz verfolgen.

[1] Beispiel: https://github.com/manfredsteyer/angular-2-component-router-sample
[2] ocLazyLoad, https://oclazyload.readme.io/