Lazy Load Angular Components with View Engine

June 12th, 2020

How to lazy load components with View Engine compiler and runtime.

The finished code is available on this GitHub repo.

If you're working in an Angular project that uses View Engine and not the latest Ivy compiler and runtime, it is not possible to lazy load components on their own. This is because View Engine needs information about all its declarable dependencies, their declarable dependencies, and so on.

Imagine you come across an advanced use case such as building a dashboard where users can add X number of widgets, potentially loading X third-party libraries such as charts, data grids, forms, etc. How do you do this whilst maintaining good performance?

We'll work together through a solution how we can do this by creating a service that can lazy load "component modules" in View Engine and also be compatible with Ivy too.

Getting Started

If you’re using Angular <= 7, you’re running on View Engine. Starting with Angular 8, Ivy was released behind a flag in experimental mode, while in Angular 9 it is active by default.

To disable Ivy in Angular 9, go to your tsconfig.json and set enableIvy to false in angularCompilerOptions.

Let's think about what we want to do here. We want to somehow lazy load a component, but we can't lazy load a component on its own with View Engine because they have to be declared in a module. We can however lazy load modules, right? We know this because Angular Router can do it:

1const routes: Routes = [
2 {
3 path: 'customers',
4 // lazy loaded router module
5 loadChildren: () =>
6 import('./customers/customers.module').then(m => m.CustomersModule)
7 },
8 {
9 path: 'orders',
10 // another lazy loaded router module
11 loadChildren: () =>
12 import('./orders/orders.module').then(m => m.OrdersModule)
13 }
14];

So, let's start there and take a look at how Angular Router does it. The first branch of the loadModuleFactory method is for the deprecated way where you specified loadChildren as a string (we'll ignore this part). The else branch specifies loadChildren as a function that calls a dynamic import and returns a module. This is exactly what we will need for the service we're going to build.

We next need to think how we'll get a reference of the component we want to use from a lazy loaded module. One way we could do this is by adding a static member (let's call it entry) to a lazy loaded module with the component as the value. So our lazy module will look something like this:

1import { NgModule } from '@angular/core';
2import { CommonModule } from '@angular/common';
3import { LazyComponent } from './lazy.component';
4
5@NgModule({
6 declarations: [LazyComponent],
7 imports: [CommonModule],
8 entryComponents: [LazyComponent]
9})
10export class LazyModule {
11 // This is the static member that lazy modules will use to indicate what component to load
12 static entry = LazyComponent;
13}

We'll call these lazy modules a "component module" from now on as the component we want to load can only be created with an associated module.

With all this in mind, let's create a file called component-module-loader.service.ts and create a public method to load the component module:

1import {
2 Compiler,
3 ComponentFactory,
4 Injectable,
5 Injector,
6 NgModuleFactory,
7 Type
8} from '@angular/core';
9
10@Injectable({ providedIn: 'root' })
11export class ComponentModuleLoaderService {
12 constructor(private compiler: Compiler, private injector: Injector) {}
13
14 async loadComponentModule<T>(
15 loadModuleCallback: () => Promise<Type<any> | NgModuleFactory<any>>,
16 injector?: Injector
17 ): Promise<ComponentFactory<T>> {
18 // create the module factory from the loaded module
19 const moduleFactory = await this.createModuleFactory(
20 await loadModuleCallback()
21 );
22
23 // create the module reference and provide flexibility of what injector to provide
24 const moduleRef = moduleFactory.create(injector || this.injector);
25
26 // by adding the `entry` static prop to the lazy loaded module we
27 // can easily get a reference of the component we want to work with
28 const component = (moduleFactory.moduleType as any).entry as Type<T>;
29
30 // retrieve and return the factory object that creates a component of the given type.
31 return moduleRef.componentFactoryResolver.resolveComponentFactory(
32 component
33 );
34 }
35
36 // this is the logic taken from the angular router code πŸ™Œ
37 private async createModuleFactory<T>(t: Type<T> | NgModuleFactory<T>) {
38 // AOT compiled module
39 if (t instanceof NgModuleFactory) {
40 return t;
41 }
42 // JIT compiled module
43 else {
44 return await this.compiler.compileModuleAsync<T>(t);
45 }
46 }
47}

Create the Component Modules

Before we test ComponentLoaderService, let's create a component module to lazy load. Run the following commands in your project:

1ng g module lazy
2ng g component lazy --module lazy --skipSelector --skipTests --inlineStyle --inlineTemplate --entryComponent

This should have created a directory called lazy with a component and module (with the component added to the module's declarations and entryComponents array).

Remember we need to add a static entry member to the module to indicate what component we want to eventually create? Let's add this to lazy.module.ts:

1// imports...
2
3@NgModule({
4 // declarations, entryComponents, etc...
5})
6export class LazyModule {
7 static entry = LazyComponent; // <-- Here
8}

We'll make our generated component more interesting and use Angular Forms and RXJS so we can later better demonstrate why lazy loading our code is so important. In lazy.component.ts, replace the code with:

1import { Component } from '@angular/core';
2import { FormControl } from '@angular/forms';
3import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
4
5@Component({
6 template: '<input type="text" [formControl]="messageControl" />'
7})
8export class LazyComponent {
9 // An instance of a FormControl used to track the value and
10 // validation status of the input control.
11 readonly messageControl = new FormControl('');
12
13 // An observable that emits an event (debounced by 300ms) every
14 // time a distinct value is made from the form control.
15 readonly messageChanges = this.messageControl.valueChanges.pipe(
16 debounceTime(300),
17 distinctUntilChanged()
18 );
19}

As we're using the formControl directive of Angular Reactive Forms in the LazyComponent view, we'll need to update lazy.module.ts to import ReactiveFormsModule from @angular/forms:

1import { CommonModule } from '@angular/common';
2import { NgModule } from '@angular/core';
3import { ReactiveFormsModule } from '@angular/forms';
4import { LazyComponent } from './lazy.component';
5
6@NgModule({
7 declarations: [LazyComponent],
8 imports: [CommonModule, ReactiveFormsModule],
9 entryComponents: [LazyComponent]
10})
11export class LazyModule {
12 static entry = LazyComponent;
13}

Lazy Load the Component Modules

Okay, so now we have our ComponentModuleLoaderService and a component module to lazy load, let's add a button to app.module.ts that when clicked will lazy load our component module and render it.

1import {
2 Component,
3 Injector,
4 ViewChild,
5 ViewContainerRef,
6 ComponentRef
7} from '@angular/core';
8import { LazyComponent } from './lazy/lazy.component';
9import { ComponentModuleLoaderService } from './component-module-loader.service';
10
11// Map of component modules to load
12const componentModules = {
13 lazy: () => import('./lazy/lazy.module').then(m => m.LazyModule)
14};
15
16@Component({
17 selector: 'app-root',
18 template: `
19 <div>
20 <button (click)="lazyLoad()">Lazy Load</button>
21
22 <div #vcr></div>
23 </div>
24 `
25})
26export class AppComponent {
27 @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef;
28
29 private componentRef: ComponentRef<LazyComponent>;
30
31 constructor(
32 private loader: ComponentModuleLoaderService,
33 public injector: Injector
34 ) {}
35
36 async lazyLoad() {
37 // don't do anything if the component ref already exists
38 if (this.componentRef) {
39 return;
40 }
41
42 // lazy load the component module πŸš€ and return us the component factory
43 const factory = await this.loader.loadComponentModule<LazyComponent>(
44 componentModules.lazy,
45 this.injector
46 );
47
48 // instantiate the component and insert it into the container view
49 const ref = this.vcr.createComponent(factory);
50
51 // as we have a reference to the lazy loaded component, we can access the instance methods
52 // also... don't forget to unsubscribe to any subscriptions πŸ‘€!
53 ref.instance.messageChanges.subscribe(console.log);
54 ref.instance.messageControl.setValue('Hi!');
55 }
56}

Test It All Out

If you start up your app with ng serve, open the network tab and click the "Lazy Load" button, you'll see our component module code is lazy loaded on click πŸ”₯:

Network output with `ng serve`

We can also test this using ng serve --prod (optimised production build) and everything's still looking good πŸ”₯πŸ”₯:

Network output with `ng serve --prod`

If you disabled Ivy previously, try re-enabling it again and run ng serve or ng serve --prod again, and the code still gets lazy loaded πŸ”₯πŸ”₯πŸ”₯.

Analyze the Bundled Code

We'll use a tool called webpack-bundle-analyzer to analyze how our application bundles are looking.

webpack-bundle-analyzer allows you to visualize the size of webpack output files with an interactive zoomable treemap.

Add the following to your package.json scripts:

1{
2 // ...
3 "scripts": {
4 // ...
5 "analyze": "ng build --prod --stats-json && webpack-bundle-analyzer ./dist/angular-lazy-load-components/stats-es2015.json"
6 }
7}

If you run npm run analyze, it will open your browser with a nice visual representation of our app bundles in the form of a zoomable treemap:

Webpack bundle analyzer

Our component module chunk in this example is named 5-es2015.<OUTPUT_HASH>.js as we're analyzing a production build of our code

The size of our main bundle is around 140 KB. If we were to eagerly load the component module we created it would be around 180 KB. We saved around 40 KB by lazy loading only one small module. Pretty cool!

Remember we imported RXJS (debounceTime and distinctUntilChanged operators) and Angular Forms in our component code? If we inspect the component module chunk and search for those modules, you can see them highlighted in red which shows they've been bundled in our chunk:

Analyze single bundle

We can go one step further and check if any of our component module code and its imports have made it's way into the other application bundles:

Analyze all bundles

As you can see all of our component module code is indeed isolated in it's own chunk and has not made its way into the main bundle. Very nice!

Conclusion

We've learnt that lazy loading components is possible in View Engine if they are loaded with a module. This gives us more granular control of what we want to lazy load compared to just lazy loading feature modules statically with Angular Router. This enables great possibilities to optimise our application further when it comes to making our initial bundle smaller and the application load time faster.

Dan Dawson-Brown
UI Developer from Brum building tech for Vermeg.