routing & modules

Karine Samyn, Benjamin Vertonghen, Thomas Aelbrecht, Pieter Van Der Helst

"Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Monday's code." - Christopher Thompson

recipe app startpoint

if you spent last class on facebook (or at the pub), branch the repo at the correct commit and follow along!

~$ git clone https://github.com/Pieter-hogent/recipeapp.git  (or git pull)
~$ cd recipeapp
~/recipeapp$ git checkout -b week7 be18b22
~/recipeapp$ npm install
      

overview.

  1. intro
    what are routing and modules and why do we need them
  2. prepare recipe app
    refactor the existing app to prepare it for routing
  3. first routes
    add the first few routes to the app module
  4. material design navigation
    install a response navigation element from material design
  5. separate routing module
    split of routing to a separate module
  6. feature modules
    introduce feature modules, for recipe and material design
  7. parameter prefetching
    prefetch parameters from parameterized routes
  8. lazy loading
    lazy load feature modules
  9. prefetch lazy loading
    prefetch (certain) lazy loaded modules

routing

  • we're making an SPA, a Single Page Application, but that doesn't mean our whole app should live on one huge, bigly webpage
  • we need a way to show different 'pages' (=component hierarchies) and link those to different urls so that bookmarking / back,forward / etc. all works as a user expects it to
  • this is what routing is all about

modules

  • we've already talked about the basics of modules, we imported some angular modules (ReactiveFormsModule and HttpClientModule)
  • and we created a few ourselves, RecipeModule en MaterialModule, facilitating separation of concerns and code reuse
  • but when routing is added we can also use modules to lazily load parts of our webapp only when they are needed

recipe app

  • right now our recipe app has one page, where we see a list of recipes (with a filter) and a form to add new recipes
  • we'll split these up, and we'll add navigation to access both

add recipe

src/app/add-recipe/add-recipe.component.ts
                constructor(
                  private fb: FormBuilder,
                  private _recipeDataService: RecipeDataService
                ) {}

                onSubmit() {
                  // ...
                  this.newRecipe.emit(new Recipe(this.recipe.value.name, ingredients));
                  this._recipeDataService
                  .addNewRecipe(new Recipe(this.recipe.value.name, ingredients));
                }         
            
right now add-recipe is part of the recipe-list component, which responds to the @Output of add-recipe we'll make the add-recipe self contained, injecting the data service and taking care of the add ourselves

appcomponent


              <div fxLayout="column" fxLayoutGap="2%">
                <app-add-recipe></app-add-recipe>
                <recipe-list></recipe-list>
              <div>
                          
            8700a3c
the AppComponent now contains two components, which we'll turn into two separate pages in a minute don't forget to add this component to the exports list as well we're ready to add the router, let's see that everything still works

routermodule

src/app/app.module.ts
  import { RouterModule, Routes } from '@angular/router';
  
  const appRoutes: Routes = [
    { path: 'recipe/list', component: RecipeListComponent },
    { path: 'recipe/add', component: AddRecipeComponent }
  ];
  
  @NgModule({
    imports: [
      BrowserModule,
      BrowserModule,
      RecipeModule,
      RouterModule.forRoot(appRoutes)
    ]
  });          
            
before we can add routes, like everything in Angular, we need to add the appropriate module: RouterModule we need to define routes, we can do that right here, first create a routes object then we need to pass these routes to the RouterModule (more on the forRoot later) in their simplest form, routes simply associate a URL with a component so if anyone goes to http://localhost:4200 /recipe/list, the RecipeListComponent should be rendered but where should the component be rendered?

router outlet

 src/app/app.component.html 
                <router-outlet></router-outlet>
            
there should be a router outlet tag somewhere, and that's where the router will render all the components so we'll replace our app component html with this router outlet there is no navigation yet, so we have to manually type the full url to see this at work let's try this out http://localhost:4200/ recipe/list

404


  const appRoutes: Routes = [
    { path: 'recipe/list', component: RecipeListComponent },
    { path: 'recipe/add', component: AddRecipeComponent },
    { path: '', redirectTo: 'recipe/list', pathMatch: 'full'},
    { path: '**', component: PageNotFoundComponent}
  ];
            4d26a16
we have two routes, but our webpage only works if we explicitly visit one of those routes, that's not what a user expects you want the empty route (http://localhost:4200/) to do something sensible too, usually redirect to your 'start' page the pathMatch is needed here, or this would match with everything (and we only want to match with an exact empty path note that a redirect is only followed once, so you can't redirect to a path that redirects again as a last route, you always want a 'catch all', typically to your 404 page so we'll add a component just to show our 404, with some static content in the html and nothing more

navigation

  • we can't expect the user to always type the correct url, we need navigation
  • angular material has a decent responsive navigation component, which is added to ng generate
  • ~$ ng generate @angular/material:material-nav --name=main-nav
              
  • remember you can always do a --dry-run first if you're unsure what will happen with ng commands

navigation

 src/app/main-nav/main-nav.component.html 
              <mat-sidenav-container class="sidenav-container">
                <mat-sidenav
                  #drawer
                  class="sidenav"
                  fixedInViewport="true"
                  [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
                  [mode]="(isHandset$ | async) ? 'over' : 'side'"
                  [opened]="!(isHandset$ | async)"
                >
                <mat-toolbar>Menu</mat-toolbar>
                  <mat-nav-list>
                    <a mat-list-item href="#">Link 1<[routerLink]="['recipe/list']">recipes</a>
                    <a mat-list-item href="#">Link 2<[routerLink]="['recipe/add']">add recipe</a>
                    <a mat-list-item href="#">Link 3</a>;
                  </mat-nav-list>
                </mat-sidenav>
                <mat-sidenav-content>
                  <mat-toolbar color="primary">
                    <button [...]>
                      <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
                    </button>
                    <span>recipeapp</span>
                  </mat-toolbar>
                  <!-- Add Content Here --><ng-content></ng-content>
                </mat-sidenav-content>
              </mat-sidenav-container>
            
there isn't much code in the typescript file, there's only an observable to signal isHandset$, needed for responsiveness in the html we'll remove the menu on top of the sidebar we'll adapt the links to show our two 'pages' if you use a href pointing to /recipe-list the page would load correctly, but the whole page would always be reloaded by using [routerLink] only the router-outlet is reloaded, which gives more of an SPA feeling note that the parameter is an array, not all urls are static, you can add url-parameters as secondary parameter finally we need to make sure the contents are loaded 'inside' this navigation component we could simply add <router-outlet></router-outlet> here but ng-content is better, makes us more reusable if you create a component, whatever is enclosed inside 'your' tag is placed here

navigation

 src/app/app-component.html 
                <app-main-nav>
                  <router-outlet></router-outlet>
                </app-main-nav>
            a75a6ea
so if you use the app-main-nav tag inside your AppComponent whatever is placed inside the tag will end up at the position of ng-content in the tag let's try this out

routing module

  • the routing part is often put into a separate routing module. This keeps your main module tidy, and makes for a clear separation of concerns
  • ~/recipapp$ ng generate module app-routing --flat --module=app
              
  • You can use ng generate for modules as well
    • --flat means "don't create a separate folder", useful if your module is essentially one file, like this one
    • --module=app makes sure this router module is imported in the app module

routing module

src/app/app-routing/app-routing.module.ts
  import { RouterModule, Routes } from '@angular/router';
  import { RecipeListComponent } from './recipe/recipe-list/recipe-list.component';
  import { PageNotFoundComponent } from './page-not-found/page-not-found.component';

  const appRoutes: Routes = [ ... ];
  
  @NgModule({
    imports: [
      RouterModule.forRoot(appRoutes)
    ],
    declarations: [],
    exports: [
      RouterModule
    ]
  })
  export class AppRoutingModule { }
                
            41d44e6
We copy the appRoutes from the AppModule, and add the necessary imports, while removing all this from the AppModule nothing is declared here, we simply import the declaration of our components needed for our routes note that we also export the RouterModule again if we now import this module in our appmodule routing will work just like before

route parameters

  • often you want your routes to have parameters themselves, to illustrate this let's add a RecipeDetail component
  • ~/recipeapp$ ng generate component recipe/RecipeDetail --module=recipe
                
  • note the recipe/ path prefix (making sure it's a subfolder of the recipe folder), and the --module=recipe to put the declaration in the RecipeModule
  • installing component
      create src/app/recipe/recipe-detail/recipe-detail.component.css
      create src/app/recipe/recipe-detail/recipe-detail.component.html
      create src/app/recipe/recipe-detail/recipe-detail.component.spec.ts
      create src/app/recipe/recipe-detail/recipe-detail.component.ts
      update src/app/recipe/recipe.module.ts
                

recipe detail route

src/app/app-routing.module.ts
  const routes = [
    { path: 'recipe/list', component: RecipeListComponent },
    { path: 'recipe/add', component: AddRecipeComponent },
    { path: 'recipe/detail/:id', component: RecipeDetailComponent},
    { path: '', redirectTo: 'recipe/list', pathMatch: 'full' },
    { path: '**', component: PageNotFoundComponent }
  ];
            
we'll add a route to our recipe routes to show this recipe detail adding a route with a parameter is done by adding a colon ( : ) before the parametername

recipe detail

src/app/recipe/recipe-detail/recipe-detail.component.ts
  export class RecipeDetailComponent implements OnInit {
    public recipe: Recipe;
  
    constructor(private route: ActivatedRoute, 
      private recipeDataService: RecipeDataService) {
    }
  
    ngOnInit() { 
      const id = this.route.snapshot.paramMap.get('id');
      this.recipeDataService.getRecipe$(id)
        .subscribe(item => this.recipe = item);
    }
  }
            f0ea708
the html of the component is not important right now (we'll simply copy a regular RecipeComponent its html for this example) so how do you access the parameter here? by injecting the ActivatedRoute in your component then get the id from the snapshot of your route after which you can use this id to request the recipe based from the data service to test this I'll quickly change the links in the app html to two id's, let's try this out

route parameter

src/app/recipe/recipe-detail/recipe-detail.component.ts
  export class RecipeDetailComponent implements OnInit {
    public recipe: Recipe;
  
    constructor(private route: ActivatedRoute, 
      private recipeDataService: RecipeDataService) {
    }
  
    ngOnInit() { 
      const id = this.route.snapshot.paramMap.get('id');
      this.recipeDataService.getRecipe$(id)
        .subscribe(item => this.recipe = item);
      this.route.paramMap.subscribe(pa =>
        this.recipeDataService.getRecipe$(pa.get('id'))
          .subscribe(item => this.recipe = item)
      );
    }
  }
            62f6acf
so what's going on? when changing the id the router routes to the same component, and it doesn't get recreated, so the OnInit is not re-executed luckily the route paramMap is an observable, so we can subscribe to changes and update our recipe that way let's try this out

route parameter

  • everything seems to work, but if we have a look at the browser console, we see 'undefined is not an object' errors inside our RecipeDetailComponent, while everything looks normal?
  • the problem is that when our component is created this.recipe is still undefined, and while we subscribed to paramMap inside our ngOnInit, that callback hasn't executed yet when the first draw happens
  • the result is immediatelly available, so the callback is called, change detection kicks in, and we have a recipe object and everything looks normal on the next draw
  • so while you don't see any errors, for a split second things went 'wrong'

route parameter prefetching

src/app/recipe/recipe.module.ts
  const routes = [
    { path: 'recipe/list', component: RecipeListComponent },
    { path: 'recipe/add', component: AddRecipeComponent },
    { path: 'recipe/detail/:id', component: RecipeDetailComponent,
      resolve: { recipe: RecipeResolver} }
  ];
            
you could fix this in the html by using recipe?.ingredients optional access but for some use cases it's not desirable that a component is loaded at all before its contents are retrieved avoiding this can be done by prefetching the data using a 'resolve guard' resolve guards will first 'resolve' a parameter, before the route is loaded there are many other guards available, most of them check for access somehow (logged in? allowed to load?)

recipe resolver

src/app/recipe/RecipeResolver.ts
  @Injectable({
    providedIn: 'root'
  })
  export class RecipeResolver implements Resolve<Recipe> { 
    constructor(private recipeService: RecipeDataService) {}
   
    resolve(route: ActivatedRouteSnapshot, 
            state: RouterStateSnapshot): Observable<Recipe> {
      return this.recipeService.getRecipe(route.params['id']);
    }
  }
            
such a resolver is a service very similar to our RecipeDataService but we implement the generic interface Resolve (from @angular/router) which means overriding the resolve method, which gets a snapshot of the route and routerstate as parameters, and should return an Observable to get this observable, we use the id param and the RecipeDataService like before in 'real' code these resolvers typically need quiet a bit of error checking, but space is at a premium on these slides so that's an 'exercise for the reader'

recipe detail component

src/app/recipe/recipe-detail/recipe-detail.component.ts
  ngOnInit() {
    this.route.data.subscribe(item => 
      this.recipe = item['recipe']);
    this.route.paramMap.subscribe(pa =>
      this.recipeDataService.getRecipe(pa.get('id'))
        .subscribe(item => this.recipe = item)
    );
  }
            
we have to change the ngOnInit of our detail component, subscribing to the paramMap would still work, but of course we want to take the recipe from the resolver, not fetch it again so we subscribe to the data attribute of the route instead, and extract the 'recipe' from it (remember we called it recipe when we specified the resolver) let's try this out this works great, but there's a drawback, your component is not loaded (nor drawn) before the resolve is done, which makes loading your page slower

module lazy loading

  • next up: lazy loading our module
  • when you create bigger apps, it's important to split them in feature modules and lazy load as much as possible
  • for our example app this is far fetched, we always need the recipe module, but if you only need certain feature modules after your user clicked a certain menu option, you don't want to force everyone to download the module everytime they visit your site
  • everybody expects webpages to load fast; less modules → less bytes → faster sites

feature module


const routes = [
  { path: 'recipe/list', component: RecipeListComponent },
  { path: 'recipe/add', component: AddRecipeComponent },
  { path: 'recipe/detail/:id',  component: RecipeDetailComponent,
      resolve: { recipe: RecipeResolver }
  }
];

@NgModule({
declarations: [
  RecipeComponent,
  // ...
],
imports: [
  CommonModule,
  MaterialModule,
  HttpClientModule,
  ReactiveFormsModule,
  RouterModule.forChild(routes)
],
providers: [HttpClientModule]
})
export class RecipeModule {}

            
before we get to the lazy loading, let's make the RecipeModule completely self contained we'll move all the recipe loading routes inside the module, using the forChild of RouterModule let's try this out so we always end up with the PageNotFoundComponent, what's going on?

app routing module

src/app/app-routing/app-routing.module.ts
      const appRoutes: Routes = [
        { path: '', redirectTo: 'recipe/list', pathMatch: 'full'},
        { path: '**', component: PageNotFoundComponent}
      ];
      
      @NgModule({
        imports: [
          RouterModule.forRoot(appRoutes, {enableTracing: true})
        ],
        declarations: [],
        exports: [
          RouterModule
        ]
      })
      export class AppRoutingModule { }
                
when encountering (what appear to be) router problems, you can always log debugging output let's try this

routing

src/app/app.module.ts
                      @NgModule({
                        declarations: [
                          AppComponent,
                          PageNotFoundComponent
                        ],
                        imports: [
                          BrowserModule,
                          AppRoutingModule,
                          RecipeModuleRecipeModule,
                          AppRoutingModule
                        ],
                        bootstrap: [AppComponent]
                      })
                      export class AppModule { }
                                337f765
the problem is that the order matters! the approutingmodule is loaded first and it has a '**' which matches everything so we never reach the routes of RecipeModule, let's swap the order let's try this out

app routing module

src/app/app-routing/app-routing.module.ts
  const appRoutes: Routes = [
    {
      path: 'recipe',
      loadChildren: () => import('./recipe/recipe.module').then(mod => mod.RecipeModule)
    },
    { path: '', redirectTo: 'recipe/list', pathMatch: 'full'},
    // { path: '', redirectTo: 'recipe/list', pathMatch: 'full'},
    { path: '**', component: PageNotFoundComponent}
  ];
            
now inside our app routing module we'll lazy load the recipe module we simply add a route to our prefix path, and add the loadChildren key to, with the feature module we want to load for now I'm even going to comment out the redirect path, if I always redirect to something in the RecipeModule, I can't show the lazy loading at work

recipe module

 src/app/recipe/recipe.module.ts 
            // ...
            const routes: Routes = [
              { path: 'recipe/list', component: RecipeListComponent },
              { path: 'recipe/add', component: AddRecipeComponent },
              {
                path: 'recipe/detail/:id',
                component: RecipeDetailComponent,
                resolve: { recipe: RecipeResolver }
              }
            ]; 
            // ....
            bfd6221 
we'll have to adapt our recipe module routing as well we had { path: 'recipe' } for the RecipeModule, meaning every url that starts with recipe/ refers to the RecipeModule this would mean we'd need to navigate to recipe/recipe/list and recipe/recipe/add, obviously not what we want so let's remove the recipe/ prefix here we also still need to remove the RecipeModule as a dependency from the app.module, since we're lazy loaded let's try this out

lazy loading

to see the lazy loading at work open the network tab of your devtools, you'll see that the (compiled) recipe module is only loaded as soon as you navigate to e.g. recipe/list/
recipe module before being lazy loaded
recipe module after being lazy loaded

prefetch lazy loading

src/app/app-routing.module.ts
  @NgModule({
    imports: [
      RouterModule.forRoot(appRoutes,
        {preloadingStrategy: PreloadAllModules})
    ],
    declarations: [],
    exports: [
      RouterModule
    ]
  })
  export class AppRoutingModule { }
            
you can prefetch lazy loaded modules, so: they are lazy loaded, but you start to load them immediatelly.... what? this makes sense though, if modules are not used on the startpage, lazy loading them makes that page loads faster, in the meantime you download them then if the user navigates he doesn't have to wait for the modules to load, you do this by adding a preloadingStrategy so simply load everything you need for the startpage and lazy load but prefetch everything else it's best to restart your angular when you change how modules are loaded sometimes it makes more sense to selectively preload modules, not prefetching parts that are rarely used (e.g. an admin portion of your site)

custom preloading strategy

src/app/SelectivePreloadStrategy.ts
  import { Injectable } from '@angular/core';
  import { PreloadingStrategy, Route } from '@angular/router';
  import { Observable, of } from 'rxjs';
  
  @Injectable({ providedIn: 'root' })
  export class SelectivePreloadStrategy implements PreloadingStrategy {
    preload(route: Route, load: Function): Observable<any> {
      if (route.data && route.data.preload) {
        console.log('preload ' + route.path);
        return load();
      }
      return of(null);
    }
  }
          
to illustrate this, let's create a preloadingstrategy that only preloads certain modules the preload method has two parameters, the route that is being considered, and the function that will perform the loading, you decide if you call the function or not (and hence preload) in this simple example, let's preload if the preload data parameter is passed in the module specification, and a log statement to show this works now we still need to update the routing module to use this

custom preloading strategy


  const appRoutes: Routes = [
  {
    path: 'recipe',
    loadChildren: () => import('./recipe/recipe.module').then(mod => mod.RecipeModule),
    data: { preload: true }
  }, ... ];
  
  @NgModule({
    imports: [
      RouterModule.forRoot(appRoutes, {
        preloadingStrategy: SelectivePreloadStrategy
      })
    ]
  })
  export class AppRoutingModule {}
            a4591be
first add the data property with preload to the route then tell the routermodule to use our new strategy to preload everything restart and once more remove default redirect to recipe/list to see this in action let's try this

there's more

  • we haven't talked about child routes (think a router-outlet inside a component which is displayed inside another router-outlet)
  • I've said nothing about secondary routes (think two router-outlet's next to each other)
  • and there's more to say about guards, but we'll get back to that next week (authentication guards)
  • but you should have a good grasp on the basics of routing and modules and how you can use them in your own apps now

summary

  • use the RouterModule to define your routes
  • you should have one forRoot() and zero or more forChild() routes defined
  • routes can have parameters, and guards, you can preload these parameters if needed
  • split your app into several feature modules, and lazy load them, this will improve your startup time (and your bandwidth usage if you selectively preload)