Lazy Loading Von Modulen Mit Angular 2

Dem Neuen Router Und Webpack

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:

Beispielanwendung

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:

Flug suchen

Hierzu wurde die Anwendung auf vier Module aufgeteilt:

Modulstruktur des Beispiels

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:

Two "Singletons"

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:

forRoot

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 Anwendung require.ensure sowie das geschachtelte require 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:

Verzögertes Laden über Dev-Tools kontrollieren

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:

Nach Login

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):

Flug suchen

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.