Lazy Load Angular Components with View Engine
June 12th, 2020How 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 setenableIvy
tofalse
inangularCompilerOptions
.
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 module5 loadChildren: () =>6 import('./customers/customers.module').then(m => m.CustomersModule)7 },8 {9 path: 'orders',10 // another lazy loaded router module11 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';45@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 load12 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 Type8} from '@angular/core';910@Injectable({ providedIn: 'root' })11export class ComponentModuleLoaderService {12 constructor(private compiler: Compiler, private injector: Injector) {}1314 async loadComponentModule<T>(15 loadModuleCallback: () => Promise<Type<any> | NgModuleFactory<any>>,16 injector?: Injector17 ): Promise<ComponentFactory<T>> {18 // create the module factory from the loaded module19 const moduleFactory = await this.createModuleFactory(20 await loadModuleCallback()21 );2223 // create the module reference and provide flexibility of what injector to provide24 const moduleRef = moduleFactory.create(injector || this.injector);2526 // by adding the `entry` static prop to the lazy loaded module we27 // can easily get a reference of the component we want to work with28 const component = (moduleFactory.moduleType as any).entry as Type<T>;2930 // retrieve and return the factory object that creates a component of the given type.31 return moduleRef.componentFactoryResolver.resolveComponentFactory(32 component33 );34 }3536 // this is the logic taken from the angular router code π37 private async createModuleFactory<T>(t: Type<T> | NgModuleFactory<T>) {38 // AOT compiled module39 if (t instanceof NgModuleFactory) {40 return t;41 }42 // JIT compiled module43 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 lazy2ng 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...23@NgModule({4 // declarations, entryComponents, etc...5})6export class LazyModule {7 static entry = LazyComponent; // <-- Here8}
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';45@Component({6 template: '<input type="text" [formControl]="messageControl" />'7})8export class LazyComponent {9 // An instance of a FormControl used to track the value and10 // validation status of the input control.11 readonly messageControl = new FormControl('');1213 // An observable that emits an event (debounced by 300ms) every14 // 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';56@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 ComponentRef7} from '@angular/core';8import { LazyComponent } from './lazy/lazy.component';9import { ComponentModuleLoaderService } from './component-module-loader.service';1011// Map of component modules to load12const componentModules = {13 lazy: () => import('./lazy/lazy.module').then(m => m.LazyModule)14};1516@Component({17 selector: 'app-root',18 template: `19 <div>20 <button (click)="lazyLoad()">Lazy Load</button>2122 <div #vcr></div>23 </div>24 `25})26export class AppComponent {27 @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef;2829 private componentRef: ComponentRef<LazyComponent>;3031 constructor(32 private loader: ComponentModuleLoaderService,33 public injector: Injector34 ) {}3536 async lazyLoad() {37 // don't do anything if the component ref already exists38 if (this.componentRef) {39 return;40 }4142 // lazy load the component module π and return us the component factory43 const factory = await this.loader.loadComponentModule<LazyComponent>(44 componentModules.lazy,45 this.injector46 );4748 // instantiate the component and insert it into the container view49 const ref = this.vcr.createComponent(factory);5051 // as we have a reference to the lazy loaded component, we can access the instance methods52 // 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
π₯:
We can also test this using ng serve --prod
(optimised production build) and
everything's still looking good π₯π₯:
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:
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:
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:
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.