I have a monolithic Angular 15 app that does not use the Router. The number of components inside it is growing and I'd like to break most of the components off into a separate module and load them separately.
My app already has a splash screen with a "loading" progress bar that advances as it fetches data from the server. I want the main AppModule to contain a minimal set of components to get things started then I'll load the rest of the components as one of the startup tasks monitored by the progress bar.
The current state of things...
app.module.ts:
import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from "@angular/forms";
import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatSidenavModule} from "@angular/material/sidenav";
import {SharedModule} from "./shared.module";
import {AppComponent} from './app.component';
import {ResizeableSidenavDirective} from "../components/resizeable-sidenav.directive";
import {SplashScreenComponent} from "../components/splash-screen/splash-screen.component";
@NgModule({
declarations: [
AppComponent,
SplashScreenComponent,
ResizeableSidenavDirective,
],
imports: [
BrowserModule,
BrowserAnimationsModule,
FormsModule,
MatProgressBarModule,
MatSidenavModule,
SharedModule
],
providers: [],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }
shared.module.ts:
import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {CommonModule} from "@angular/common";
import {LeafletModule} from "@asymmetrik/ngx-leaflet";
import {AppComponent} from './app.component';
import {MapViewComponent} from "../components/map-view/map-view.component";
import {Toaster} from "../components/toaster";
@NgModule({
declarations: [
MapViewComponent,
Toaster,
],
imports: [
BrowserAnimationsModule,
CommonModule,
LeafletModule,
],
exports: [
MapViewComponent,
Toaster,
],
providers: [],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SharedModule { }
lazy.modules.ts:
import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {CommonModule} from "@angular/common";
import {FormsModule} from "@angular/forms";
import {MatDialogModule} from "@angular/material/dialog";
import {MatSliderModule} from "@angular/material/slider";
import {SharedModule} from "./shared.module";
... big list of component imports ...
@NgModule({
declarations: [
MyFirstComponent,
MySecondComponent,
MyThirdComponent,
...
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatSliderModule,
SharedModule
],
exports: [
MyFirstComponent,
MySecondComponent,
MyThirdComponent,
...
],
providers: [],
bootstrap: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LazyModule { }
While the question I'd like to ask is how to do the lazy-load from within the splash screen, I can't even get the above to build. There are many build errors from the LazyModule components saying things like:
'mat-slider' is not a known element
(yet MatSliderModule is an included import of LazyModule)
No pipe found with name 'number'.
(yet CommonModule is also an included import)
Can't bind to 'ngModel' since it isn't a known property of 'input'.
(yet FormsModule is an included import)
'my-second' is not a known element:
(lazy component MyFirstComponent's HTML referencing MySecondComponent)
All of the above problems go away if I add LazyModule after SharedModule in the AppModule imports. Oddly enough, it'll also build with just the import of ./lazy.module even without adding LazyModule to the "imports" list. But, of course, this returns me to the single, monolithic main.js file that I'm trying to break up. So two questions:
How do I detach LazyModule from AppModule and have it build?
What call do I make in my initialization to load LazyModule and how do I get notified that the loading is complete?
Update1: I managed to fix the first error (mat-slider not known) by moving the import of MatSliderModule from lazy.module to shared.module. This makes no sense to me since mat-slider isn't used anywhere but by the one lazy component. This technique did not help with "number pipe not found" (CommonModule) or "can't bind ngModel" (FormsModule).
Eugene gave a very good answer and it was of great help in figuring out what I wanted to do.
My question, as phrased, was not actually what I wanted to know. It should have been something like:
Can I break my source code into parts such that I can download the essentials first and the rest later during a "splash" startup sequence?
The short answer to that question is "no". My investigations revealed that there may be a long answer that revolves around include/exclude directives in the angular.json file but it seemed complicated with likely maintenance headaches and generally counter to the designs of Angular.
In the end, I used the built-in Angular support to create and load modules. What I'll present below is not significantly different than snippets I found elsewhere. None of those ever made sense to me, however, because I was missing a very fundamental concept that everyone seemed to take for granted:
Separation of code into independently loaded javascript files is automatic.
Angular simply does this for you. Unlike C/C++ where you manually group files into .a libraries or Java into .jar files, with Angular you just don't explicitly instantiate anything you want to load separately. It can be imported in order to access fields and methods off of the classes so long as there is no direct creation of new objects of that type.
Whether components are grouped into modules or declared standalone:true, the trick is to simply not reference them directly. Independent pieces are collected into separate .js files, including code from node_modules used only by them, which can then be loaded on-demand (aka "lazy loading").
Here's an example:
publish.module.ts:
import {CommonModule} from "@angular/common";
import {FormsModule} from "@angular/forms";
import {NgModule} from "@angular/core";
import {PublishStartComponent} from "./publish-start/publish-start.component";
import {PublishContinueComponent} from "./publish-continue/publish-continue.component";
import {PublishFinalComponent} from "./publish-final/publish-final.component";
import {PublishResultsComponent} from "./publish-results/publish-results.component";
import {RegionPlacerComponent} from "./region-placer.component";
@NgModule({
declarations: [
PublishStartComponent,
PublishContinueComponent,
PublishFinalComponent,
PublishResultsComponent,
RegionPlacerComponent,
],
imports: [
CommonModule, // |number
FormsModule, // ngModel
]
})
export class PublishModule {
getPublishStartFactory() { return PublishStartComponent }
getPublishContinueFactory() { return PublishContinueComponent }
getPublishFinalFactory() { return PublishFinalComponent }
getPublishResultsFactory() { return PublishResultsComponent }
getRegionPlacerFactory() { return RegionPlacerComponent }
}
In Angular 15, Lazy-loading the file and getting access to this is just two lines:
const {PublishModule} = await import ("../../components/publish/publish.module")
const pminstance = createNgModule(PublishModule, this.injector).instance
Then the classes can be instantiated by using the "factory" return types:
let thing = new (pmintstance.getPublishStartFactory())(...)
Or it can be passed to functions needing a type:
this.dialogService.open(pmintstance.getPublishStartFactory(), {...})
In my case, it looks like this:
myapp.ts:
...
import {PublishModule} from "../../components/publish/publish.module";
...
@Component({...})
export class MyApp {
...
private pmInstance: PublishModule|undefined
...
constructor(dialogService: MatDialog, injector: Injector) {...}
...
private async onPublishButtonFirstClick() {
const {PublishModule} = await import ("../../components/publish/publish.module")
this.pmInstance = createNgModule(PublishModule, this.injector).instance
let dref = this.dialogService.open(this.pmInstance.getPublishStartFactory(), {
...
})
dref.afterClosed().subscribe((rid: string) => {
if (rid == null || rid == "") return
this.regionName = dref.componentInstance.regionName
this.regionProjection = dref.componentInstance.regionType
this.imageUrl = dref.componentInstance.imageUrl!
this.imageSize = dref.componentInstance.imageSize!
const rpc = document.getElementById("overlay-container")!
const injector = Injector.create({
providers: [
{provide: 'imageUrl', useValue: this.imageUrl!},
{provide: 'imageSize', useValue: this.imageSize!},
]
})
this.regionPlacerView = this.injector.get<ViewContainerRef>(ViewContainerRef);
const rp = this.regionPlacerView.createComponent(this.pmInstance!.getRegionPlacerFactory(), {
injector: injector
})
this.regionPlacer = rp.instance
})
}
...
}
There's more to all this, such as creating "shared" modules for those components used by both the base code and the lazy code but nothing needs to be changed with regard to how it's used. Simply access the components of the shared module normally within both base/lazy sides and Angular will take care of doing the right thing (in this case: splitting "shared" into its own file but loading it as part of index.html).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With