Faster maps with lazy-loaded components

Makes maps faster by lazy loading components without changing the main route using Angular Auxiliary router.

·

11 min read

Featured on Hashnode
Faster maps with lazy-loaded components

Hello friends 👋

Parham here, with another step-by-step guide. This time, we will learn how to use Angular Router to lazy-load UI pieces that are not visible and doing it without changing the main route. So we can keep the first context.

Here is the final demo deployed using Vercel. https://ng-lazy-loaded-modal.vercel.app

Here is a video demo of the final result.

The main principle is the same here regardless of the technique or frontend technology you choose. You want to reduce the number of resources that the browser needs to initially download and parse in order to render your application. This is also referred to as optimising the critical rendering path. You can use different methods to optimize the critical rendering path. Lazy loading is one of them. If you are interested in the Critical Rendering Path topic, please check this course from Google web fundamentals.

A reminder

By default, NgModules are eagerly loaded, which means that as soon as the app loads, so do all the NgModules, whether or not they are immediately necessary. For large apps with lots of routes, consider lazy loading - a design pattern that loads NgModules as needed. Lazy loading helps keep initial bundle sizes smaller, which in turn helps decrease load times. angular.io/guide/lazy-loading-ngmodules

Following the link above, it is easy to lazy load a feature module by simply defining a route for it. Here is an example to remind you how it’s done.

const routes: Routes = [
    {
        path: 'map',
        loadChildren: () => import('./map/map.module').then(m => m.MapModule)
    }
];

Doing this, Angular will separately bundle the map module, and it will only be loaded when you access the /map route.

The problem

The previous example is a good solution until you come across use cases with components or pieces of UI that cannot have a separate route. Let me explain what I mean.

Have a look at this example:

1_Y8CbkVRJ1OTTp1fqyWjYng.png

1_t2Giqnkj6llOoO1MLzaHIQ.png

1_x5sPF-Lbgvu_H-Ns920nVA.png

All the screenshots show the same map view. This view is created using a feature module with the route /map that hosts many other features that show up based on user interaction.

  • Search modals that show up when the user is searching on the map.
  • A collapsible Layer selectors panel to find a layer to add to the map. So it will be only visible when expanded.

  • Layer details, which are only visible when a user clicks on a layer in the side panel to see its details.

  • Also, some more UI pieces that can show up when the user selects a specific menu. Like Draw controls, Print form, Set Region control and more.
  • Plus, each one of these UIs utilises services and other dependencies under the hood, which can quickly add up to map module bundle size.

The goal is to load these pieces of UI only if the user needs them. This will help reduce the initial bundle size for the Angular app and improve the performance.

I cannot lazy-load the other feature modules like the map module because accessing any routes that define other feature modules means leaving and unloading the map that will not work for my use case. For example, I need to stay on the map and show the modal.

So I want to lazy-load feature modules without leaving the map module. (/map route) Now that we have a better picture of the problem, let's discuss the solution.

There are a couple of ways we can solve this problem:

  1. Use Dynamic imports using the import() function and let Webpack do its magic to lazy load a feature module. This feature is available in Angular 9+.
  2. Use Angular Router to lazy load a feature module.

My solution demonstrates the second option which uses Angular router to handle lazy loading and state of the application (e.g. a modal being open or not). This means URLs are bookmarkable. For example, if you open this URL

https://invest.agriculture.vic.gov.au/#/map/(m:s/%5B16030153.917455776,-4319458.318312469%5D)?lat=144.07623978175104&lon=-36.15776605793607&z=11&bm=bm0&l=mb0:y:100

the app will automatically open the search modal and use the passed query params to search for exact lat/lng and layers that was passed through URL.

If you are interested in the first solution please also checkout Lazy Load Modal Components in Angular by Netanel Basal.

The solution - Angular Auxiliary Routes to the rescue

First, let's see what the Auxiliary route is?

To put it simply, they are just plain routes like the primary route. The only difference is that auxiliary routes are mapped to a different router outlet which must be named (unlike the primary outlet).

The following video shows how Invest in Victorian Agriculture website uses this capability. Here I am starting in the context of the /map route, and all the JS required for the visible part of the view is loaded with the map module. Please pay attention to network requests when I open the search results modal. It’s only then that Angular loads the JS files required for the SearchModule and my modal.

invest.agriculture.vic.gov.au is too complicated for the learning purpose, so I made an Example application replicating the lazy-loaded modal and map. This application is available on Github. So you can download and follow the same steps mentioned in the rest of my article.

Let’s see how the routing works here

This is the syntax for the URL

http://base-path/#/primary-route-path/(secondary-outlet-name:route-path)

Which translate to http://localhost:4200/#/map/(map-outlet:modal).

  • http://localhost:4200 is the base path. I am using HashLocationStrategy, which is why I have /#/ after the base URL.
  • /map is the primary route's path for the map view.
  • The remaining part in the parenthesis is my auxiliary route (route within the /map route) (map-outlet:modal).

Let’s breakdown the auxiliary route

  • ( open and ) close parenthesis indicates the beginning and end of my auxiliary route.
  • Next is map-outlet, which is my outlet name. This is the name I selected for my secondary outlet.
  • Followed by : that separates the outlet name from the actual path. In this case, the path is modal for the modal. I am using short names here to shorten my URL length, but you can use any name.

How to build the auxiliary routes?

First, I need a secondary router outlet (named router outlet). You can have one default(primary) router outlet and many named(secondary) router outlets as you need in Angular.

The RouterOutlet is a directive from the router library that is used as a component. It acts as a placeholder that marks the spot in the template where the router should display the components for that outlet.

Like any other Angular have we will have a primary outlet defined in the app.component.html where we render the primary routes.

<ul class="menu">
  <li>
    <a routerLink="home">Home</a>
  </li>
  <li>|</li>
  <li>
    <a routerLink="map">Map</a>
  </li>
</ul>

<div class="content">
  <router-outlet></router-outlet>
</div>

Here is the top-level application router config defining the /map route, which lazy loads the map module.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full',
  },
  {
    path: 'home',
    loadChildren: () => import('./home/home.module').then((m) => m.HomeModule),
  },
  {
    path: 'map',
    loadChildren: () => import('./map/map.module').then((m) => m.MapModule),
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes, { useHash: true })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

The primary router outlet will render /home and /map. Given the configuration above, when the browser URL for this application becomes /map, the router matches that URL to the route path /map and displays the MapComponent. The rendered HTML will have the <app-map> as a sibling element to the RouterOutlet that you've placed in the host component's template.

Screen Shot 2021-07-26 at 8.14.43 pm.png

I will place another router outlet in the map.component.html which is a named router outlet (my secondary outlet). Other floating components like modals or sliding panels can use this named router outlet to show within the map context.

<router-outlet name='map-outlet'></router-outlet>

I need to specify the outlet name in the map module router when I define the child routes.

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MapComponent } from './map.component';

const routes: Routes = [
  {
    path: '',
    component: MapComponent,
    children: [
      {
        path: 'modal',
        outlet: 'map-outlet',
        loadChildren: () => import('../modal-wrapper/modal-wrapper.module')
          .then((m) => m.ModalWrapperModule),
      },
    ],
  },
];

@NgModule({
            imports: [RouterModule.forChild(routes)],
            exports: [RouterModule],
          })
export class MapRoutingModule {
}

That’s all you need to define a route within a route, and Angular will take care of bundling the lazy-loaded feature modules separately and also lazy loading the feature modules as you navigate to their respective route. Here is the code structure for the map module. 1_myjsCs_EOV1MMA34YatR0A.png

Now you can easily use the Angular router to navigate to the Modal route. Take a look at the openModal() method.

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { TileLayer } from 'leaflet';

@Component({
  selector: 'app-map',
  templateUrl: './map.component.html',
  styleUrls: ['./map.component.scss']
})
export class MapComponent {
  options = {
    zoom: 5,
    maxZoom: 18,
    preferCanvas: true,
    attributionControl: true,
    center: [
      -28.690259,
      131.5190514,
    ],
    layers: [
      new TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, attribution: '...' })
    ]
  };

  constructor(private router: Router) {}

  public openModal(): void {
    this.router.navigate(['map', {outlets: {['map-outlet']: ['modal']}}]);
  }
}

As you can see, I am passing multiple commands to the navigate method.

Basically telling the router to navigate to /map/(map-outlet:modal) where /map is the map module path, map-outlet is my named outlet, and modal is the path for the modal module.

And the last piece of the puzzle to make the router work. Place a named <router-outlet></router-outlet> in the map view HTML.

<div leaflet class="map" [leafletOptions]="options"></div>
<router-outlet name='map-outlet'></router-outlet>
<button class="map__overlayButton" mat-raised-button color="primary" (click)="openModal()">Open modal</button>

By putting an outlet in the map.component.html I am telling the Angular where to render the content of the named router outlet (map-outlet).

How does the code look like for the modal module?

The architecture for the module is a bit more complicated than the other feature modules in my application since with Angular material dialog, I need to pass a component to the this.dialog.open() method. Here is how the code structure looks like.

This is modal-wrapper.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ModalComponent } from './modal/modal.component';
import { ModalWrapperComponent } from './modal-wrapper.component';
import { ModalWrapperRoutingModule } from './modal-wrapper-routing.module';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [ModalComponent, ModalWrapperComponent],
    imports: [
        CommonModule,
        ModalWrapperRoutingModule,
        MatDialogModule,
        MatButtonModule,
    ]
})
export class ModalWrapperModule { }

Here is modal-wrapper-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ModalWrapperComponent } from './modal-wrapper.component';

const routes: Routes = [
  {
    path: '',
    component: ModalWrapperComponent,
  },
];

@NgModule(
  {
    imports: [RouterModule.forChild(routes)],
    exports: [RouterModule],
  },
)
export class ModalWrapperRoutingModule {
}

this is modal-wrapper.component.ts

import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ModalComponent } from './modal/modal.component';
import { take } from 'rxjs/operators';
import { Router } from '@angular/router';

@Component({
  selector: 'app-modal-wrapper',
  template: '',
})
export class ModalWrapperComponent implements OnInit, OnDestroy {

  private dialogRef: MatDialogRef<ModalComponent>;
  private closedOnDestroy = false;

  constructor(private dialog: MatDialog,
              private router: Router,
  ) {
  }

  ngOnInit(): void {
    this.openDialog();
  }

  private openDialog(): void {
    this.dialogRef = this.dialog.open(ModalComponent,
      {
        minWidth: '700px',
        minHeight: '400px'
      });

    this.dialogRef.afterClosed()
      .pipe(take(1))
      .subscribe(result => {
        if (this.closedOnDestroy) {
          return;
        }
        return this.router.navigate(['map']);
      });
  }

  public ngOnDestroy(): void {
    this.closedOnDestroy = true;
    this.dialogRef.close();
  }

}

and here is modal.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-modal',
  templateUrl: './modal.component.html',
  styleUrls: ['./modal.component.scss']
})
export class ModalComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
  }

}

and finally modal.component.html

<h1 mat-dialog-title>🎊 Hey, you made it! I am a route enabled modal 🎉</h1>
<h3 mat-dialog-content>You may refresh the page but I will be back.</h3>
<div mat-dialog-actions>
  <button mat-raised-button mat-dialog-close color="primary">close me</button>
</div>

Conclusion

We learned how to use Angular Router to lazy-load UI components based on user interactions without changing the main route.

I have simplified the code here for this demonstration, but you can imagine that a feature module can be more complicated and use more services or other third-party dependencies.

So being able to lazy-load modules with Auxiliary routes will be a great boost to the application performance.

  • As a result of this architecture, your modal is route-enabled, which means you can refresh the page, and Angular will open the modal automatically. This is great for bookmarking and sharing URLs. In this case, I can share the link with someone, and they can see the search result for the exact lat/long I used for my search.
  • By opening each lazy-loaded route defined similarly to the search module on the map, I navigate away from the search module route, and the search modal will get destroyed. This is because they use the same router. This can be good or bad based on the use case. In my case, this is exactly what I needed. For example, I want to close the search modal when I open the layer selector and vice versa. This will naturally happen as I move away from the previous route. Without this architecture, I will need to keep some flags locally in the map component or on the app state to make sure I close the layer selector component before opening the search modal, which can get really messy in a big application.

  • You can skip the wrapper component (modal-wrapper.component.ts) in your implementation if you have a simpler UI to show/hide. The wrapper component is handy to support the usage of Angular Material Dialog.

  • You can prefetch the lazy-loaded modules using the preloadingStrategy: PreloadAllModules or use a custom preloadingStrategy as described in the following article.

Also, if you require lazy-loading a specific component like a modal without routing, you might find the following article useful.

I hope you learned something useful from this article.

As usual, if you have any questions, please leave me a comment here or DM me on Twitter.

Here is the code repository: https://github.com/pazel-io/ng-lazy-loaded-modal

Here is the final demo deployed using Vercel.

https://ng-lazy-loaded-modal.vercel.app

Twitter: _pazel

Did you find this article valuable?

Support Parham by becoming a sponsor. Any amount is appreciated!

Â