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/