In the previous article I wrote about storing context menu action definitions in a scalable way using a Command Pattern (no more 1000 lines ActionService
🎉). In the showcase application I used dialogs that were loaded eagerly, which means all the dialog modules are being downloaded to the browser at once despite displaying only some of them most of the time. Let's fix it!
After this article ends you will have known how to easily manage dozens of dialogs that are scalable and independent of each other and the rest of the app.
A gif from the showcase application presenting dialogs sequencing:
Link to the article:
Angular Architecture: How to use Command Pattern to manage large amount of context menu actions
Maciej Sawicki ・ Nov 9 '20
The Problem
Currently, the dialogs structure looks like this:
Each dialog:
- Has a module,
- Has a component,
- Has a data input model structure.
So far so good. We show a dialog using Angular Material's MatDialog
Service:
constructor(private matDialog: MatDialog) {
const dialogData: JobUserAssignDialogDataModel = {
jobId: params.jobId,
};
this.matDialog
.open(JobUserAssignDialogComponent, {data: dialogData})
}
Unfortunately, we have 2 problems with this approach:
1. All dialogs are bundled with AppModule
In order for Angular to instantiate the JobUserAssignDialogComponent
it needs to be declared somewhere and since it was declared in the JobUserAssignDialogModule
we also need to import it in the AppModule
:
@NgModule({
declarations: [
AppComponent
],
imports: [
...,
JobUserAssignDialogModule,
ConfirmationDialogModule
],
bootstrap: [AppComponent]
})
export class AppModule { }
Now all the dialogs are bundled with main.js
file, which results in downloading them all even if a user is on the page that doesn't use any of them at all!
2. No static type enforcement for dialog data
In the expression .open(JobUserAssignDialogComponent, {data: dialogData})
the data
field has type of any
until we manually specify given type. This is very error prone, because it's extremely easy to mistype something or just not type something at all.
We need a method, which automatically matches dialog data interface with the dialog.
The Solution
Just like in Action Definition's case from the previous article it would be wise to have a Dialog Service per each dialog module, so that we won't end up with one gigantic service.
First let's create a base class for a Dialog Service:
@Directive()
// tslint:disable-next-line:directive-class-suffix
export abstract class AsyncDialog<ComponentType, DataType, ReturnType = unknown> {
constructor(protected matDialog: MatDialog) {
}
abstract async open(data: DataType): Promise<MatDialogRef<ComponentType, ReturnType>>;
}
This class is mostly for typing consistency.
The AsyncDialog
class consumes 3 generic parameters:
- ComponentType - a dialog class.
- DataType - a structure a dialog receives from its opener.
- ReturnType - a structure a dialog returns to its opener.
We also have to annotate it with @Directive()
, because since version 10 Angular removes all types from constructor parameters of a class without a decorator and we need to inject MatDialog
.
There is also 1 abstract method:
-
open()
- template for opening a dialog
After creating a base class we can create a JobUserAssignDialogService
:
@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<JobUserAssignDialogComponent, JobUserAssignDialogDataModel, UserModel> {
async open(data: JobUserAssignDialogDataModel): Promise<MatDialogRef<JobUserAssignDialogComponent, UserModel>> {
// the magical part of importing a module asynchronously
await import('../job-user-assign-dialog.module');
return this.matDialog.open(JobUserAssignDialogComponent, {data});
}
}
The JobUserAssignDialogService
inherits from AsyncDialog
base class created before. In the 3 generic parameters we specify what component this service is responsible for, what it consumes and what it returns.
The magical part is await import('...')
. This tells angular to fetch the module file if it has not yet been downloaded. Then after that we just open the dialog like we did before.
Note: We use async
keyword, because asynchronous importing returns promise and we need to wait with opening the dialog until the operation completes.
The example below shows that a module file is fetched only when we click the action.
Unexpected problem
The gif shows that the dialog module is being fetched asynchronously, however, if we run ng build
and take a closer look at the source code of the JobListViewComponent
we can find JobUserAssignDialogComponent
bundled with the app instead of on the lazy loded module.
WAIT WHAT?
Shouldn't the dialog component be lazy loaded? We have just downloaded the module lazily! What is going on!
...
It turns out the line responsible for this behaviour is the one which opens the dialog. But why?
@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<...> {
async open(data: JobUserAssignDialogDataModel): Promise<...> {
await import('../job-user-assign-dialog.module');
// THIS IS THE CULPRIT!
return this.matDialog.open(JobUserAssignDialogComponent, {data});
}
}
Dependency graph
If we analyze the import chaining (what imports what), we can draw the following graph:
From view Jobs view, where we invoke the action, to the dialog there are 3 imports along the way:
-
JobsComponent
injectsJobAssignAction
and uses the type as value (Angular preserves a type of constructor params, so that it knows what to inject). -
JobAssignAction
injectsJobUserAssignDialogService
and uses the type as value. -
JobUserAssignDialogService
opens theJobUserAssignDialogComponent
and uses its type as value.
So in order for JobsComponent
to be run by Angular it needs to have a dependency of JobUserAssignDialogComponent
, because all along the way it was used as a value!!!
So how can we fix it?
Using as value vs type
First we need to understand some differences between value and a type:
As a value
In the example below we use a class as a value, which means it is still there after compiling to JavaScript.
// typescript
const componentClass = JobUserAssignDialogComponent;
// javascript
const componentClass = JobUserAssignDialogComponent;
As a type
In the example below we use a class as a type, which means it is completely removed from JavaScript with all of its imports.
// typescript
const component: JobUserAssignDialogComponent = {};
// javascript
const component = {};
Lazy loading a component
Now we somehow need to use JobUserAssignDialogComponent
as a value, but without importing it as a value. We can ask the module that we lazily imported for some help!
@NgModule({
declarations: [JobUserAssignDialogComponent],
imports: [...],
})
export class JobUserAssignDialogModule {
static getComponent(): typeof JobUserAssignDialogComponent {
return JobUserAssignDialogComponent;
}
}
It's now the module's responsibility to import the component as a value, by having a static method called getComponent()
, and returning it to us.
After that we can adjust the dialog service.
@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<JobUserAssignDialogComponent,...> {
async open(data: JobUserAssignDialogDataModel): Promise<MatDialogRef<...> {
const importedModuleFile = await import('../job-user-assign-dialog.module');
// the `importedModuleFile` has all the imports of the file
// and since one of them is `JobUserAssignDialogModule`
// we can use it to invoke previously defined `getComponent()` method
return this.matDialog.open(
importedModuleFile.JobUserAssignDialogModule.getComponent(),
{data},
);
}
}
Et voilà ! The dialog component is now bundled with the module, which is being loaded lazily.
What about component class used as a generic parameter AsyncDialog<JobUserAssignDialogComponent,...>
? Fortunately, it is used as a type so it is completely removed in the compilation phase.
Final project structure
The scructure now looks like this:
Each dialog:
- Has a dedicated module,
- Has a dedicated component,
- Has a dedicated data input structure,
- Has a dedicated async service.
If we now add 50 more dialogs to our app and use them all in one view none of them would come immediately. It is handy in case some of them uses 2MB library that plays a violin while mining bitcoins at the same time. The view would be slim and light until we trigger the right action which in result would download it all.
Don't forget to remove Dialog Module imports from your
AppModule
The GitHub repo can be found below as well as a StackBlitz demo:
Humberd / lazy-loaded-dialogs-in-angular
Showcase of lazy loaded Angular dialogs
Conclusion
During the last 5 minutes we've learned why loading all dialogs at once is not optimal. We discovered the problem why dialogs are bundled with main.js
file. We've also understood the difference between using a class as a value and using it as a type. At the very end we implemented a solution for all dialogs to by asynchronous by lazy loading their module classes and using them as a proxy to get a component class reference, so that Angular can instantiate it.
I hope you are impressed as much as I was when I discovered it was possible :)
In the next article I will write something about Angular. Probably.
See you around.
Top comments (0)