Um die Ladezeiten einer Single Page Application (SPA) zu optimieren, bietet es sich an, einzelne Bestandteile nicht sofort sondern erst bei Bedarf zu laden. Seit RC 5 unterstützt Angular 2 dieses Vorgehen, welches auch als Lazy Loading bekannt ist. Dazu kommen die neu eingeführten Module, mit der eine Anwendung strukturiert und in mehrere wiederverwendbare Einheiten aufgesplittet werden kann, zum Einsatz.
Mit diesem Beitrag veranschauliche ich den Umgang mit Modulen und Lazy Loading anhand eines Beispiels, welches zum Bundling webpack verwendet. Das vollständige Beispiel findet sich hier. Wer mit den hier beschriebenen Techniken ein eigenes Beispiel erstellen möchte, findet für den Start auf den GitHub-Seiten des Angular-2-Teams ein sehr einfaches Seed-Projekt, welches auch webpack nutzt.
Beispiel
Das hier verwendete Beispiel bietet über den Router die Menüpunkte Home
, Login
und Flug Buchen
an:
Die ersten beiden Menüpunkte stehen ab dem Start der Anwendung zur Verfügung. Die Anwendungsteile für den Menüpunkt Flug Buchen
lädt die Anwendung erst bei Bedarf nach:
Hierzu wurde die Anwendung auf vier Module aufgeteilt:
Das AppModul
beinhaltet die Root-Component, welche die gesamte Anwendung repräsentiert und beim Bootstrapping von Angular 2 erzeugt wird. Solche Module nennt man deswegen auch Root-Module. Es referenziert das HomeModule
mit den Komponenten für die ersten beiden Menübefehle. Daneben lädt es über den Router beim ersten Aufruf des Befehls Flug Buchen
das FlugModule
in die Ausführungsumgebung. Sowohl HomeModule
als auch FlugModule
verweisen auf das SharedModule
, welches allgemeine Komponenten und Services beherbergt.
Allgemeiner AuthService im SharedModule
Das SharedModule bietet unter anderem einen AuthService
an. Dieser simuliert das An- und Abmelden des Benutzers und hält dessen Namen und Stadt vor. Wie das nachfolgende Listing zeigt, handelt es sich dabei aus Gründen der Vereinfachung um eine Implementierung für äußerst ehrliche Benutzer:
@Injectable()
export class AuthService {
isLoggedIn: boolean = false;
userName: string = null;
city: string = "Wien";
login() {
this.isLoggedIn = true;
this.userName = "Max";
this.city = "Graz";
}
logout() {
this.isLoggedIn = false;
this.userName = null;
this.city = "Wien";
}
}
Wichtig für die weitere Betrachtung des Beispiels ist, dass der Service bei einem anonymen Benutzer die Standardwert Wien
als Standardwert annimmt, während der verwendete Demo-Benutzer zur Stadt Graz
zugeordnet ist.
Das SharedModule
importiert das CommonModule
sowie das FormsModule
. Ersteres beinhaltet allgemeine Direktiven, wie ngIf, ngFor oder ngStyle und letzteres fügt Unterstützung für die Arbeit mit Formularen hinzu:
@NgModule({
imports: [
CommonModule,
FormsModule
],
declarations: [
OrtValidatorDirective,
OrtAsyncValidatorDirective,
DateComponent,
OrtPipe
],
exports: [
OrtValidatorDirective,
OrtAsyncValidatorDirective,
DateComponent,
OrtPipe
],
providers: []
})
export class SharedModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: SharedModule,
providers: [AuthService]
}
}
}
Der Abschnitt declarations
legt die Inhalte des Moduls fest. Dabei kann es sich um Direktiven, Komponenten und Pipes handeln. Unter exports
listet das Modul jene Inhalte, die es exportiert und somit anderen Modulen zur Verfügung stellt.
Etwas spannender gestaltet sich die Eigenschaft providers
. Sie listet jene Provider, welche das Modul global einrichten soll.
Hier würde man einen Provider für den vorhin besprochenen AuthService
erwarten. Da jedoch das verzögert geladene FlugModule
auch auf das SharedModule
zugreift, ist das keine gute Idee, denn aufgrund der Implementierung von Lazy Loading richtet Angular 2 solche Services für verzögert geladene erneut (!) ein. Unterm Strich gäbe es also zwei Instanzen des "Singletons" AuthService
:
Dieser Umstand ist kaum wünschenswert, da er unweigerlich zu Inkonsistenzen führt. Beispielsweise könnte die eine Instanz über den angemeldeten Benutzer informieren während die zweite davon ausgeht, dass dieser noch nicht bekannt ist. Für die Lösung dieses Problems gibt das Angular-Team ein Muster (eigentlich ein Idiom) vor. Demnach ist das Modul in zwei Ausprägungen anzubieten: Einmal ohne und einmal mit Provider. Per Definition stellt eine statische Methode forRoot
die Ausprägung mit Provider zur Verfügung. Dazu erzeugt Sie eine Instanz von ModuleWithProviders
, welche das Modul sowie die Provider gruppiert. Dieses ModuleWithProviders
ist über das Root-Module, welches die Services einmalig global zur Verfügung stellt, zu referenzieren. Bei allen anderen Modulen kommt die Variante ohne Provider zum Einsatz:
Die Auflistung
providers
definiert tatsächlich globale Provider, da Module keinen eigenen Injector bekommen. Um Provider mit beschränktem Wirkungsbereich zu definieren, sind diese auf der Ebene einer Komponente zu registrieren.
Definition des Feature-Modules HomeModule
Das beim Start der Anwendung zu ladende HomeModule
importiert unter anderem das im letzten Abschnitt besprochene SharedModule
:
@NgModule({
imports: [
CommonModule,
SharedModule
],
declarations: [
HomeComponent,
LoginComponent
],
exports: [
HomeComponent,
LoginComponent
],
providers: []
})
export class HomeModule {
}
Darüber hinaus exportiert es eine HomeComponent
, welche als Startseite fungiert, und eine LoginComponent
, die mit dem AuthService
den aktuellen Benutzer an- bzw. abmeldet.
Definition des Feature-Modules FlugModule
Das verzögert geladene FlugModule
kommt mit einer eigenen Routen-Konfiguration. Darin findet sich unter anderem eine Route für die Komponente FlugBuchenComponent
, welche einen leeren Pfad aufweist und somit als Startseite des Moduls fungiert:
const FLUG_ROUTES = [{
path: '',
component: FlugBuchenComponent,
canActivate: [AuthGuard],
children: [
{
path: 'flug-suchen',
component: FlugSuchenComponent
},
{
path: 'flug-suchen-reactive',
component: FlugSuchenReactiveComponent
},
{
path: 'passagier-suchen',
component: PassagierSuchenComponent
},
{
path: 'flug-edit/:id',
component: FlugEditComponent,
canDeactivate: [FlugEditGuard]
}
]
}];
Außerdem richtet diese Konfiguration ein paar Child-Routes ein und verweist auf zwei hier nicht näher betrachtete Guards, welche das Provider-Array FLUG_ROUTE_PROVIDERS
einrichtet:
export const FLUG_ROUTE_PROVIDERS = [
AuthGuard,
FlugEditGuard
];
Da die hier betrachtete Routen-Konfiguration jene der Root-Component erweitert, ist sie an die statische Methode RouterModule.forChild
zu übergeben:
export const FlugRouterModule = RouterModule.forChild(FLUG_ROUTES);
Diese Methode erzeugt für das FlugModule
ein konfiguriertes RouterModule
. Dabei handelt es sich abermals um ein ModuleWithProviders
.
Das FlugModule
erhält auch eine FlugSuchenComponent
:
@Component({
selector: 'flug-suchen',
template: require('./flug-suchen.component.html'),
providers: [],
styles: [require('./flug-suchen.component.css')]
})
export class FlugSuchenComponent {
public von: string = "";
public nach: string = "";
public datum: string = (new Date()).toISOString();
public selectedFlug: Flug;
constructor(private flugService: FlugService, private authService: AuthService) {
this.von = authService.city;
}
[...]
}
Sie lässt sich den AuthService
aus dem SharedModule
injizieren und übernimmt die Stadt des aktuellen Benutzers in die Eigenschaft von
, um sie als Abflugflughafen vorzuschlagen.
Das FlugModule
referenziert unter anderem das SharedModule
sowie das FlugRouterModule
mit der besprochenen Routing-Konfiguration:
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
SharedModule,
FlugRouterModule
],
declarations: [
FlugBuchenComponent,
FlugCardComponent,
FlugSuchenComponent,
FlugSuchenReactiveComponent,
PassagierSuchenComponent,
FlugEditComponent
],
providers: [
FlugService,
FLUG_ROUTE_PROVIDERS
]
})
export class FlugModule {
}
Das betrachtete Modul deklariert auch die von der Routen-Konfiguration verwendeten Komponenten. Daneben richtet es einen Provider für einen FlugService
ein. Zusätzlich nimmt es das Array FLUG_ROUTE_PROVIDERS
mit den Guards in die providers
-Auflistung auf.
Routen für Root-Module AppModule
Die Routen für das AppModule
verweisen auf die HomeComponent
sowie auf die LoginComponent
. Die Route flug-buchen
verweist auf keine Komponente. Stattdessen gibt sie über loadChildren
einen Lambda-Ausdruck an, welcher das FlugModule
bei Bedarf nachlädt:
export const ROUTE_CONFIG: Routes = [
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: HomeComponent
},
{
path: 'login',
component: LoginComponent
},
{
path: 'flug-buchen',
loadChildren: () => System.import('./modules/flug/flug.module').then(m => m.FlugModule)
},
{
path: '**',
redirectTo: 'home'
}
];
Die Anweisung System.import
veranlasst webpack2 das Bundle an dieser Stelle zu splitten. Auf diese Weise ergibt sich für den referenzierten Anwendungsteil ein eigenes Bundle, welches nach der Nomiklatur von webpack auch als Chunk bezeichnet wird. Zur Laufzeit lädt System.import
diesen Chunk bei Bedarf nach.
Als dieser Text verfasst wurde, war webpack2 noch in der BETA-Phase und Version 1.x die aktuell stabile. Diese nutzt zum Definieren weiterer Chunks die Methode require.ensure
, welche ähnlich wie System.import
funktioniertm jedoch Callbacks anstatt von Promises einsetzt. Da loadChildren
vorsieht, dass der übergebene Lambda-Ausdruck das nachgelade Modul über einen Promise liefert, ist der Aufruf dieser Methode in einem solchen Promise zu kapseln:
{
path: 'flug-buchen',
loadChildren: () => new Promise((resolve) => {
(require as any).ensure([], (require: any) => {
resolve(require('./modules/flug/flug.module').FlugModule);
})
})
},
Webpack nutzt die Aufrufe von
require.ensure
nicht nur zur Laufzeit für das Nachladen sondern auch beim Bundling als Markierungspunkte für Chunk-Grenzen. Damit beim Bundling diese Chunk-Grenzen klar identifizierbar sind, muss die Anwendungrequire.ensure
sowie das geschachtelterequire
mit fixen Werten aufrufen. Dies verhindert leider ein Verstecken des hier gezeigten etwas spärlichen Aufrufs in einer Hilfsmethode.
Der Vollständigkeit halber zeigt das nachfolgende Listing die Schreibweise, die beim Einsatz des Module-Loaders SystemJS zum Einsatz kommt. Dabei wird lediglich ein String mit dem Namen der Datei, welche das nachzuladende Modul beheimatet, und dem Modulnamen anzugeben:
{
path: 'flug-buchen',
loadChildren: 'app/modules/flug/flug.module#FlugModule'
}
Um auch für diese Konfiguration ein konfiguriertes RouterModule
zu erhalten, ist sie an RouterModule.forRoot
zu übergeben:
export const AppRoutesModule = RouterModule.forRoot(ROUTE_CONFIG);
Im Gegensatz zu forChild
kommt forRoot
für Routen im Root-Module zum Einsatz. Das auf diese Weise erhaltene AppRoutesModule
wird exportiert.
Per Konvention kommen
forRoot
-Methoden NUR in der Root-Module zum Einsatz!
Definition des Root-Modules AppModule
Das AppModule
deklariert die AppComponent
, welche als Root-Component zum Einsatz kommt, und importiert das vorhin besprochene Modul AppRoutesModule
mit der Routen-Konfiguration:
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpModule,
FormsModule,
ReactiveFormsModule,
AppRoutesModule,
HomeModule,
SharedModule.forRoot()
],
providers: [
{ provide: "BASE_URL", useValue: "http://www.angular.at" }
],
bootstrap: [
AppComponent
]
})
export class AppModule {
}
Dabei ist zu beachten, dass das SharedModule
über seine statische Methode forRoot
einzubinden ist, zumal es sich hierbei um das Root-Module handelt.
Bundling mit Webpack
Beim Bundling mit Webpack wird nun für das FlugModule
ein eigener Chunk erzeugt. Da dafür kein Name festgelegt wurde, handelt es sich um einen unbenannten Chunk, welchen webpack mit einer fortlaufenden Zahlen benennt. Webpack 1 startet dabei mit 1; Webpack 2 mit 0:
>webpack
Hash: a16aebe9bab18c48aa57
Version: webpack 2.1.0-beta.21
Time: 13539ms
Asset Size Chunks Chunk Names
0.js 562 kB 0 [emitted]
app.js 280 kB 1 [emitted] app
tests.js 5.74 kB 2 [emitted] tests
vendor.js 2.77 MB 3 [emitted] vendor
0.js.map 683 kB 0 [emitted]
app.js.map 268 kB 1 [emitted] app
tests.js.map 6.98 kB 2 [emitted] tests
vendor.js.map 2.78 MB 3 [emitted] vendor
[418] ./app spec\.ts$ 160 bytes {2} [built]
[722] multi tests 28 bytes {2} [built]
[723] multi vendor 40 bytes {3} [built]
+ 1029 hidden modules
Test des Lazy-Loadings
Um den Einsatz von Lazy Loading zu kontrollieren, kann der Netzwerktraffic betrachtet werden. Dazu bietet sich unter anderem das Registerblatt Network
in den Developer-Tools von Chrome an. Funktioniert alles wie gewollt, wird der Chunk mit dem FlugModule
(hier 0.js) erst beim Wechsel in den Menüpunkt Flug buchen
angefordert:
Test des über SharedModule geteilten AuthService
Um zu prüfen, ob der AuthService
des SharedModuls
trotz Lazy Loading nur ein einziges Mal instanziiert wird, sollte sich der Benutzer zunächst anmelden:
Wird anschließend im nachgeladenen FlugModule
die Stadt Graz
vorgeschlagen, kann davon ausgegangen werden, dass lediglich ein globaler AuthServices
existiert. Der Grund dafür ist, dass der AuthService
nach dem Anmelden einen Benutzer aus Graz simuliert (siehe Listing zu AuthService
, oben):
Experiment mit globalen Services
Zum Erforschen der Problematik mit den mehrfachen Singletons beim Einsatz von Lazy Loading könnte man zu Testzwecken - und nur zu Testzwecken - dem FlugModule
das SharedModule
samt Provider spendieren. Dazu kommt ein weiteres Mal die Methode forRoot
zum Einsatz:
@NgModule({ // _ _
imports: [ // ___ (~ )( ~)
CommonModule, // / \_\ \/ /
RouterModule, // | D_ ]\ \/
FormsModule, // | D _]/\ ReactiveFormsModule, // \___/ / /\ FlugRouterModule, // (_ )( _)
// Alt: SharedModule //
SharedModule.forRoot() // <-- Böse!!!! Nur zum Test!!!
],
declarations: [
FlugBuchenComponent,
FlugCardComponent,
FlugSuchenComponent,
FlugSuchenReactiveComponent,
PassagierSuchenComponent,
FlugEditComponent
],
providers: [
FlugService,
FLUG_ROUTE_PROVIDERS
]
})
export class FlugModule {
}
Das Ergebnis sollte nun jenes unerwünschte aus der oben diskutierten Abbildung sein: FlugModule
hat seinen eigenen AuthService
. Da das Anmelden des Benutzers mit der Login-Komponente im Module HomeModule
erfolgt, kennt dieser AuthService
innerhalb des FlugModules
diesen Benutzer nicht. Er geht davon aus, dass der Benutzer nie angemeldet wurde und schlägt somit beim Flugsuchen immer den Wert Wien
vor. Wie eingangs anhand der Implementierung gezeigt, handelt es sich dabei um den Standardwert für anonyme Benutzer.
Nach diesem Experiment sollte der Eintrag SharedModule.forRoot()
wieder durch SharedModule
ersetzt werden, damit sich das System wie gewünscht verhält.
Zusammenfassung
Ab RC 5 kann eine Angular-2-Anwendung in mehrere wiederverwendbare Module unterteilt werden. Um die Startzeit zu optimieren, bietet der Router die Möglichkeit, diese Module erst bei Bedarf zu laden (Lazy Loading). Damit das funktioniert müssen die nachzuladenden Module in einem eigenen Bundle platziert werden. Webpack kümmert sich darum, wenn die Anwendung das Modul über require.ensure
angefordert. Etwas mehr Komfort bietet webpack2, welches stattdessen die Promise-basierte Methode System.import
unterstützt.
Besondere Aufmerksamkeit benötigen Services, welche in mehreren separat geladenen Modulen zum Einsatz kommen. In diesem Fall darf der Service nur auf der Ebene des Root-Modules registriert werden, da Angular 2 ansonsten den "Singleton" mehrfach einrichtet. Der konsequente Einsatz des hierfür vom Angular-2-Team vorgeschlagenen Musters hilft, solche Fälle zu erkennen und adäquat zu behandeln.