ABP Framework Angular UI has some built-in modules, each of which is a separate npm package. Every package has an exported module that can be loaded by the router. When loaded, these modules introduce several pages to the application via child routes.
To allow a degree of customization, we have an extensibility system, and this requires configuration. So, a question arises: How can we configure modules loaded by loadChildren
? It may look like a requirement specific to our use case, but I believe the method described here may help other subjects.
The Setup
Here is a mere module that attaches a simple component to the path the router loads it.
import { Component, Inject, InjectionToken, NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
export const FOO = new InjectionToken<string>("FOO");
@Component({
selector: "app-foo",
template: "{{ foo }} works!"
})
export class FooComponent {
constructor(@Inject(FOO) public readonly foo: string) {}
}
@NgModule({
imports: [
RouterModule.forChild([
{
path: "",
component: FooComponent
}
])
],
declarations: [FooComponent],
providers: [
{
provide: FOO,
useValue: "foo"
}
]
})
export class FooModule {}
Let's load this module with the router.
import { Component, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { RouterModule } from "@angular/router";
import { FooModule } from "./foo.module";
@Component({
selector: "app-root",
template: "<router-outlet></router-outlet>"
})
export class AppComponent {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{
path: "",
loadChildren: () => FooModule
}
])
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
Do you wish lazy-loading? Well, then we need to do this:
// remove FooModule from imports
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{
path: "",
loadChildren: () => import('./foo.module').then(m => m.FooModule),
}
])
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
Voila: "foo works!". Simple, isn't it? So far, there is nothing impressive but bear with me.
The Twist
Now, let's make a small change to introduce some configuration options.
// other imports are removed for brevity
import { ModuleWithProviders } from "@angular/core";
@NgModule({
imports: [
RouterModule.forChild([
{
path: "",
component: FooComponent
}
])
],
declarations: [FooComponent]
})
export class FooModule {
static withOptions(foo = "foo"): ModuleWithProviders<FooModule> {
return {
ngModule: FooModule,
providers: [
{
provide: FOO,
useValue: foo
}
]
};
}
}
You have probably used or created a few modules with the forRoot pattern before. What we did is here similar: We used a static method called withOptions
to make our module configurable. However, we cannot call this method in loadChildren
. Eager or lazy, while loading modules with Angular router, we need to return an NgModuleFactory
, not ModuleWithProviders
. Let's fix this.
The Factory
Angular core package exports an abstract class called NgModuleFactory
. We will extend it, and implement the abstract methods to convert a ModuleWithProviders
to a module factory for the router.
import {
Compiler,
Injector,
ModuleWithProviders,
NgModuleFactory,
NgModuleRef,
StaticProvider,
Type
} from "@angular/core";
export class ChildModuleFactory<T> extends NgModuleFactory<T> {
get moduleType(): Type<T> {
return this.moduleWithProviders.ngModule;
}
constructor(private moduleWithProviders: ModuleWithProviders<T>) {
super();
}
create(parentInjector: Injector | null): NgModuleRef<T> {
const injector = Injector.create({
parent: parentInjector,
providers: this.moduleWithProviders.providers as StaticProvider[]
});
const compiler = injector.get(Compiler);
const factory = compiler.compileModuleSync(this.moduleType);
return factory.create(injector);
}
}
We pass a ModuleWithProviders
to the ChildModuleFactory
constructor. The following steps happen when the RouterConfigLoader
calls the create
method:
- A new injector is created, using the parent injector and providers from the
ModuleWithProviders
in the process. Compiler
is retrieved via that injector.compileModuleSync
method accesses themoduleType
property, which returns thengModule
from theModuleWithProviders
, and creates a factory with it.- Finally, the module is created using that
factory
.
The Solution
Now we will put this factory in use. We are going to add a new static method.
// other imports are removed for brevity
import { NgModuleFactory } from "@angular/core";
import { ChildModuleFactory } from "./child-module-factory";
@NgModule(/* module metadata is removed for brevity */)
export class FooModule {
static withOptions(foo = "foo"): ModuleWithProviders<FooModule> {
return {
ngModule: FooModule,
providers: [
{
provide: FOO,
useValue: foo
}
]
};
}
static asChild(...params: FooOptions): NgModuleFactory<FooModule> {
return new ChildModuleFactory(FooModule.withOptions(...params));
}
}
type FooOptions = Parameters<typeof FooModule.withOptions>;
The asChild
static method takes the ModuleWithProviders
returned by the withOptions
method, creates a new instance of ChildModuleFactory
, and returns it. Guess what? We can call this method inside loadChildren
.
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{
path: "",
loadChildren: () =>
import("./foo.module").then(m => m.FooModule.asChild("bar"))
}
])
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
What we see on the screen is now "bar works!". Nice! Besides, you can load the module eagerly and still configure it.
// other imports are removed for brevity
import { FooModule } from "./foo.module";
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{
path: "",
loadChildren: () => FooModule.asChild("bar"),
}
])
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
Conclusion
Of course, you can create a wrapper module and pass options through it. Or, maybe you choose to provide tokens directly, and that's perfectly fine. I have described nothing special, but it's a clean and reusable way to achieve loading configurable Angular modules.
I have created a StackBlitz playground where you can see the ChildModuleFactory
in action.
Thanks for reading. Have a beautiful day.
josh.svk 3 years ago
ModuleWithProviders is only expected to be part of import and while ChildModuleFactory seems like a nice way to lazy load, it produces unexpected behavior when it comes to merging providers. Have a look at this comment and related issues https://github.com/angular/angular/issues/43171#issuecomment-899221539